Fixing myriad bugs in loan payment calculation.
diff --git a/api/src/main/java/io/mifos/individuallending/api/v1/domain/product/ChargeIdentifiers.java b/api/src/main/java/io/mifos/individuallending/api/v1/domain/product/ChargeIdentifiers.java
index 65e8117..c0e0c56 100644
--- a/api/src/main/java/io/mifos/individuallending/api/v1/domain/product/ChargeIdentifiers.java
+++ b/api/src/main/java/io/mifos/individuallending/api/v1/domain/product/ChargeIdentifiers.java
@@ -23,29 +23,29 @@
@SuppressWarnings("unused")
public interface ChargeIdentifiers {
String LOAN_FUNDS_ALLOCATION_NAME = "Loan funds allocation";
- String LOAN_FUNDS_ALLOCATION_ID = nameToIdentifier(LOAN_FUNDS_ALLOCATION_NAME);
+ String LOAN_FUNDS_ALLOCATION_ID = "loan-funds-allocation";
String RETURN_DISBURSEMENT_NAME = "Return disbursement";
- String RETURN_DISBURSEMENT_ID = nameToIdentifier(RETURN_DISBURSEMENT_NAME);
+ String RETURN_DISBURSEMENT_ID = "return-disbursement";
String INTEREST_NAME = "Interest";
- String INTEREST_ID = nameToIdentifier(INTEREST_NAME);
+ String INTEREST_ID = "interest";
String ALLOW_FOR_WRITE_OFF_NAME = "Allow for write-off";
- String ALLOW_FOR_WRITE_OFF_ID = nameToIdentifier(ALLOW_FOR_WRITE_OFF_NAME);
+ String ALLOW_FOR_WRITE_OFF_ID = "allow-for-write-off";
String LATE_FEE_NAME = "Late fee";
- String LATE_FEE_ID = nameToIdentifier(LATE_FEE_NAME);
+ String LATE_FEE_ID = "late-fee";
String DISBURSEMENT_FEE_NAME = "Disbursement fee";
- String DISBURSEMENT_FEE_ID = nameToIdentifier(DISBURSEMENT_FEE_NAME);
+ String DISBURSEMENT_FEE_ID = "disbursement-fee";
String DISBURSE_PAYMENT_NAME = "Disburse payment";
- String DISBURSE_PAYMENT_ID = nameToIdentifier(DISBURSE_PAYMENT_NAME);
+ String DISBURSE_PAYMENT_ID = "disburse-payment";
String TRACK_DISBURSAL_PAYMENT_NAME = "Track disburse payment";
- String TRACK_DISBURSAL_PAYMENT_ID = nameToIdentifier(TRACK_DISBURSAL_PAYMENT_NAME);
+ String TRACK_DISBURSAL_PAYMENT_ID = "track-disburse-payment";
String LOAN_ORIGINATION_FEE_NAME = "Loan origination fee";
- String LOAN_ORIGINATION_FEE_ID = nameToIdentifier(LOAN_ORIGINATION_FEE_NAME);
+ String LOAN_ORIGINATION_FEE_ID = "loan-origination-fee";
String PROCESSING_FEE_NAME = "Processing fee";
- String PROCESSING_FEE_ID = nameToIdentifier(PROCESSING_FEE_NAME);
+ String PROCESSING_FEE_ID = "processing-fee";
String REPAYMENT_NAME = "Repayment";
- String REPAYMENT_ID = nameToIdentifier(REPAYMENT_NAME);
- String TRACK_RETURN_PRINCIPAL_NAME = "Return principal";
- String TRACK_RETURN_PRINCIPAL_ID = nameToIdentifier(TRACK_RETURN_PRINCIPAL_NAME);
+ String REPAYMENT_ID = "repayment";
+ String TRACK_RETURN_PRINCIPAL_NAME = "Track return principal";
+ String TRACK_RETURN_PRINCIPAL_ID = "track-return-principal";
String MAXIMUM_BALANCE_DESIGNATOR = "{maximumbalance}";
String RUNNING_BALANCE_DESIGNATOR = "{runningbalance}";
String PRINCIPAL_ADJUSTMENT_DESIGNATOR = "{principaladjustment}";
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentService.java b/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentService.java
index 439141a..20d2d37 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentService.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentService.java
@@ -35,9 +35,9 @@
import javax.annotation.Nullable;
import java.math.BigDecimal;
+import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
-import java.time.ZoneId;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
@@ -354,13 +354,14 @@
final BigDecimal loanPaymentSize,
final int minorCurrencyUnitDigits) {
BigDecimal balanceAdjustment = BigDecimal.ZERO;
+ BigDecimal currentRunningBalance = runningBalance;
final Map<ChargeDefinition, CostComponent> costComponentMap = new HashMap<>();
for (Map.Entry<ChargeDefinition, CostComponent> entry : accruedCostComponents.entrySet()) {
costComponentMap.put(entry.getKey(), entry.getValue());
- if (chargeDefinitionTouchesCustomerLoanAccount(entry.getKey()))
+ if (chargeDefinitionTouchesAccount(entry.getKey(), AccountDesignators.CUSTOMER_LOAN))
balanceAdjustment = balanceAdjustment.add(entry.getValue().getAmount());
}
@@ -370,35 +371,35 @@
for (final ScheduledCharge scheduledCharge : partitionedCharges.get(false))
{
final CostComponent costComponent = costComponentMap
- .computeIfAbsent(scheduledCharge.getChargeDefinition(),
- chargeIdentifier -> constructEmptyCostComponent(scheduledCharge));
+ .computeIfAbsent(scheduledCharge.getChargeDefinition(), CostComponentService::constructEmptyCostComponent);
final BigDecimal chargeAmount = howToApplyScheduledChargeToBalance(scheduledCharge)
- .apply(maximumBalance, runningBalance)
+ .apply(maximumBalance, currentRunningBalance)
.setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
- if (chargeDefinitionTouchesCustomerLoanAccount(scheduledCharge.getChargeDefinition()))
+ if (chargeDefinitionTouchesAccount(scheduledCharge.getChargeDefinition(), AccountDesignators.CUSTOMER_LOAN))
balanceAdjustment = balanceAdjustment.add(chargeAmount);
costComponent.setAmount(costComponent.getAmount().add(chargeAmount));
+ currentRunningBalance = currentRunningBalance.add(chargeAmount);
}
final BigDecimal principalAdjustment = loanPaymentSize.subtract(balanceAdjustment);
for (final ScheduledCharge scheduledCharge : partitionedCharges.get(true))
{
final CostComponent costComponent = costComponentMap
- .computeIfAbsent(scheduledCharge.getChargeDefinition(),
- chargeIdentifier -> constructEmptyCostComponent(scheduledCharge));
+ .computeIfAbsent(scheduledCharge.getChargeDefinition(), CostComponentService::constructEmptyCostComponent);
final BigDecimal chargeAmount = applyPrincipalAdjustmentCharge(scheduledCharge, principalAdjustment)
.setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
- if (chargeDefinitionTouchesCustomerLoanAccount(scheduledCharge.getChargeDefinition()))
+ if (chargeDefinitionTouchesAccount(scheduledCharge.getChargeDefinition(), AccountDesignators.CUSTOMER_LOAN))
balanceAdjustment = balanceAdjustment.add(chargeAmount);
costComponent.setAmount(costComponent.getAmount().add(chargeAmount));
+ currentRunningBalance = currentRunningBalance.add(chargeAmount);
}
return new CostComponentsForRepaymentPeriod(
runningBalance,
costComponentMap,
- balanceAdjustment);
+ balanceAdjustment.negate());
}
private static BigDecimal applyPrincipalAdjustmentCharge(
@@ -407,9 +408,9 @@
return scheduledCharge.getChargeDefinition().getAmount().multiply(principalAdjustment);
}
- private static CostComponent constructEmptyCostComponent(ScheduledCharge scheduledCharge) {
+ private static CostComponent constructEmptyCostComponent(final ChargeDefinition chargeDefinition) {
final CostComponent ret = new CostComponent();
- ret.setChargeIdentifier(scheduledCharge.getChargeDefinition().getIdentifier());
+ ret.setChargeIdentifier(chargeDefinition.getIdentifier());
ret.setAmount(BigDecimal.ZERO);
return ret;
}
@@ -453,13 +454,20 @@
}
}
- private static boolean chargeDefinitionTouchesCustomerLoanAccount(final ChargeDefinition chargeDefinition)
+ private static boolean chargeDefinitionTouchesCustomerVisibleAccount(final ChargeDefinition chargeDefinition)
{
- return chargeDefinition.getToAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
- chargeDefinition.getFromAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
- (chargeDefinition.getAccrualAccountDesignator() != null && chargeDefinition.getAccrualAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN));
+ return chargeDefinitionTouchesAccount(chargeDefinition, AccountDesignators.CUSTOMER_LOAN) ||
+ chargeDefinitionTouchesAccount(chargeDefinition, AccountDesignators.ENTRY);
}
+
+ private static boolean chargeDefinitionTouchesAccount(final ChargeDefinition chargeDefinition, final String accountDesignator)
+ {
+ return chargeDefinition.getToAccountDesignator().equals(accountDesignator) ||
+ chargeDefinition.getFromAccountDesignator().equals(accountDesignator) ||
+ (chargeDefinition.getAccrualAccountDesignator() != null && chargeDefinition.getAccrualAccountDesignator().equals(accountDesignator));
+ }
+
private static LocalDate today() {
- return LocalDate.now(ZoneId.of("UTC"));
+ return LocalDate.now(Clock.systemUTC());
}
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/IndividualLoanService.java b/service/src/main/java/io/mifos/individuallending/internal/service/IndividualLoanService.java
index b7a09e2..23eb766 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/IndividualLoanService.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/IndividualLoanService.java
@@ -20,7 +20,6 @@
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPayment;
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPaymentPage;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
-import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.api.v1.domain.Product;
import io.mifos.portfolio.service.internal.service.ChargeDefinitionService;
import io.mifos.portfolio.service.internal.service.ProductService;
@@ -38,8 +37,6 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import static io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers.REPAYMENT_ID;
-
/**
* @author Myrle Krantz
*/
@@ -79,9 +76,16 @@
precision);
final BigDecimal geometricMeanAccrualRate = accrualRatesByPeriod.values().stream().collect(RateCollectors.geometricMean(precision));
- final BigDecimal loanPaymentSize = loanPaymentInContextOfAccruedInterest(caseParameters.getMaximumBalance(), accrualRatesByPeriod.size(), geometricMeanAccrualRate);
+ final BigDecimal loanPaymentSize = loanPaymentInContextOfAccruedInterest(
+ caseParameters.getMaximumBalance(),
+ accrualRatesByPeriod.size(),
+ geometricMeanAccrualRate);
- final List<PlannedPayment> plannedPaymentsElements = getPlannedPaymentsElements(caseParameters.getMaximumBalance(), minorCurrencyUnitDigits, scheduledCharges, loanPaymentSize);
+ final List<PlannedPayment> plannedPaymentsElements = getPlannedPaymentsElements(
+ caseParameters.getMaximumBalance(),
+ minorCurrencyUnitDigits,
+ scheduledCharges,
+ loanPaymentSize);
final Set<ChargeName> chargeNames = scheduledCharges.stream()
.map(IndividualLoanService::chargeNameFromChargeDefinition)
@@ -128,56 +132,43 @@
chargeDefinitionsMappedByAccrueAction);
}
- private static class ScheduledChargeComparator implements Comparator<ScheduledCharge>
- {
- @Override
- public int compare(ScheduledCharge o1, ScheduledCharge o2) {
- int ret = o1.getScheduledAction().when.compareTo(o2.getScheduledAction().when);
- if (ret == 0)
- ret = o1.getScheduledAction().action.compareTo(o2.getScheduledAction().action);
- if (ret == 0)
- return o1.getChargeDefinition().getIdentifier().compareTo(o2.getChargeDefinition().getIdentifier());
- else
- return ret;
- }
- }
-
static private List<PlannedPayment> getPlannedPaymentsElements(
final BigDecimal initialBalance,
final int minorCurrencyUnitDigits,
final List<ScheduledCharge> scheduledCharges,
final BigDecimal loanPaymentSize) {
- final Map<Period, SortedSet<ScheduledCharge>> orderedScheduledChargesGroupedByPeriod
+ final Map<Period, Set<ScheduledCharge>> orderedScheduledChargesGroupedByPeriod
= scheduledCharges.stream()
- .collect(Collectors.groupingBy(scheduledCharge -> {
- final ScheduledAction scheduledAction = scheduledCharge.getScheduledAction();
- if (ScheduledActionHelpers.actionHasNoActionPeriod(scheduledAction.action))
- return new Period(null, null);
- else
- return scheduledAction.repaymentPeriod;
- },
- Collectors.mapping(x -> x,
- Collector.of(
- () -> new TreeSet<>(new ScheduledChargeComparator()),
- SortedSet::add,
- (left, right) -> { left.addAll(right); return left; }))));
+ .collect(Collectors.groupingBy(IndividualLoanService::getPeriodFromScheduledCharge,
+ Collectors.mapping(x -> x, Collectors.toSet())));
- final SortedSet<Period> sortedRepaymentPeriods
- = orderedScheduledChargesGroupedByPeriod.keySet().stream()
- .collect(Collector.of(TreeSet::new, TreeSet::add, (left, right) -> { left.addAll(right); return left; }));
+ final List<Period> sortedRepaymentPeriods
+ = orderedScheduledChargesGroupedByPeriod.keySet().stream()
+ .sorted()
+ .collect(Collector.of(ArrayList::new, List::add, (left, right) -> { left.addAll(right); return left; }));
BigDecimal balance = initialBalance.setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
final List<PlannedPayment> plannedPayments = new ArrayList<>();
for (final Period repaymentPeriod : sortedRepaymentPeriods)
{
- final SortedSet<ScheduledCharge> scheduledChargesInPeriod = orderedScheduledChargesGroupedByPeriod.get(repaymentPeriod);
+ final BigDecimal currentLoanPaymentSize;
+ if (repaymentPeriod.isDefined()) {
+ if (balance.compareTo(loanPaymentSize) < 0)
+ currentLoanPaymentSize = balance;
+ else
+ currentLoanPaymentSize = loanPaymentSize;
+ }
+ else
+ currentLoanPaymentSize = BigDecimal.ZERO;
+
+ final Set<ScheduledCharge> scheduledChargesInPeriod = orderedScheduledChargesGroupedByPeriod.get(repaymentPeriod);
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
CostComponentService.getCostComponentsForScheduledCharges(
Collections.emptyMap(),
scheduledChargesInPeriod,
balance,
balance,
- loanPaymentSize,
+ currentLoanPaymentSize,
minorCurrencyUnitDigits);
final PlannedPayment plannedPayment = new PlannedPayment();
@@ -187,19 +178,17 @@
plannedPayment.setRemainingPrincipal(balance);
plannedPayments.add(plannedPayment);
}
- if (balance.compareTo(BigDecimal.ZERO) != 0)
- {
- final PlannedPayment lastPayment = plannedPayments.get(plannedPayments.size() - 1);
- final Optional<CostComponent> lastPaymentPayment = lastPayment.getCostComponents().stream()
- .filter(x -> x.getChargeIdentifier().equals(REPAYMENT_ID)).findAny();
- lastPaymentPayment.ifPresent(x -> {
- x.setAmount(x.getAmount().subtract(lastPayment.getRemainingPrincipal()));
- lastPayment.setRemainingPrincipal(BigDecimal.ZERO.setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN));
- });
- }
return plannedPayments;
}
+ private static Period getPeriodFromScheduledCharge(final ScheduledCharge scheduledCharge) {
+ final ScheduledAction scheduledAction = scheduledCharge.getScheduledAction();
+ if (ScheduledActionHelpers.actionHasNoActionPeriod(scheduledAction.action))
+ return new Period(null, null);
+ else
+ return scheduledAction.repaymentPeriod;
+ }
+
private BigDecimal loanPaymentInContextOfAccruedInterest(
final BigDecimal initialBalance,
final int periodCount,
@@ -208,7 +197,7 @@
throw new IllegalStateException("To calculate a loan payment there must be at least one payment period.");
final MonetaryAmount presentValue = AnnuityPayment.calculate(Money.of(initialBalance, "XXX"), Rate.of(geometricMeanOfInterest), periodCount);
- return BigDecimal.valueOf(presentValue.getNumber().doubleValueExact()).negate();
+ return BigDecimal.valueOf(presentValue.getNumber().doubleValueExact());
}
private List<ScheduledCharge> getScheduledCharges(final List<ScheduledAction> scheduledActions,
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/Period.java b/service/src/main/java/io/mifos/individuallending/internal/service/Period.java
index 7718553..2b28eb5 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/Period.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/Period.java
@@ -66,6 +66,10 @@
return this.getBeginDate().compareTo(date) <= 0 && this.getEndDate().compareTo(date) > 0;
}
+ boolean isDefined() {
+ return beginDate != null || endDate != null;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/IndividualLoanServiceTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/IndividualLoanServiceTest.java
index f672c6d..c5859c5 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/IndividualLoanServiceTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/IndividualLoanServiceTest.java
@@ -46,7 +46,6 @@
*/
@RunWith(Parameterized.class)
public class IndividualLoanServiceTest {
-
private static class ActionDatePair {
final Action action;
final LocalDate localDate;
@@ -86,8 +85,19 @@
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 List<ChargeDefinition> chargeDefinitions;
+ private Set<String> 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,
+ TRACK_DISBURSAL_PAYMENT_ID,
+ TRACK_RETURN_PRINCIPAL_ID,
+ DISBURSE_PAYMENT_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
@@ -112,18 +122,8 @@
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);
+ TestCase chargeDefinitions(final List<ChargeDefinition> newVal) {
+ this.chargeDefinitions = newVal;
return this;
}
@@ -154,6 +154,8 @@
private final TestCase testCase;
private final IndividualLoanService testSubject;
private final Product product;
+ private final Map<String, List<ChargeDefinition>> chargeDefinitionsByChargeAction;
+ private final Map<String, List<ChargeDefinition>> chargeDefinitionsByAccrueAction;
private static TestCase simpleCase()
@@ -164,31 +166,28 @@
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;
+ final List<ChargeDefinition> defaultChargesWithFeesReplaced =
+ chargesWithInterestRate(0.01).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());
- 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));
+ .minorCurrencyUnitDigits(2)
+ .caseParameters(caseParameters)
+ .initialDisbursementDate(initialDisbursementDate)
+ .chargeDefinitions(defaultChargesWithFeesReplaced)
+ .expectChargeInstancesForActionDatePair(Action.OPEN, initialDisbursementDate, Collections.singletonList(processingFeeCharge))
+ .expectChargeInstancesForActionDatePair(Action.APPROVE, initialDisbursementDate,
+ Collections.singletonList(loanOriginationFeeCharge));
}
private static TestCase yearLoanTestCase()
@@ -200,13 +199,13 @@
caseParameters.setPaymentCycle(new PaymentCycle(ChronoUnit.MONTHS, 1, 0, null, null));
caseParameters.setMaximumBalance(BigDecimal.valueOf(200000));
- final Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = constructCharges(0.10);
+ final List<ChargeDefinition> charges = chargesWithInterestRate(0.10);
return new TestCase("yearLoanTestCase")
- .minorCurrencyUnitDigits(3)
- .caseParameters(caseParameters)
- .initialDisbursementDate(initialDisbursementDate)
- .chargeDefinitionsMappedByAction(chargeDefinitionsMappedByAction);
+ .minorCurrencyUnitDigits(3)
+ .caseParameters(caseParameters)
+ .initialDisbursementDate(initialDisbursementDate)
+ .chargeDefinitions(charges);
}
private static TestCase chargeDefaultsCase()
@@ -217,40 +216,24 @@
caseParameters.setPaymentCycle(new PaymentCycle(ChronoUnit.WEEKS, 1, 1, 0, 0));
caseParameters.setMaximumBalance(BigDecimal.valueOf(2000));
- final Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = constructCharges(0.05);
+ final List<ChargeDefinition> charges = chargesWithInterestRate(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)));
+ .minorCurrencyUnitDigits(2)
+ .caseParameters(caseParameters)
+ .initialDisbursementDate(initialDisbursementDate)
+ .chargeDefinitions(charges);
}
- private static Map<String, List<ChargeDefinition>> constructCharges(final double interestRate) {
+ private static List<ChargeDefinition> chargesWithInterestRate(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())));
+ defaultLoanCharges.forEach(x -> {
+ if (x.getIdentifier().equals(ChargeIdentifiers.INTEREST_ID))
+ x.setAmount(BigDecimal.valueOf(interestRate));
+ });
- 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);
+ return defaultLoanCharges;
}
private static ChargeDefinition getFixedSingleChargeDefinition(
@@ -271,25 +254,6 @@
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;
@@ -299,7 +263,15 @@
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);
+ 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);
testSubject = new IndividualLoanService(productServiceMock, chargeDefinitionServiceMock, new PeriodChargeCalculator());
}
@@ -320,32 +292,34 @@
.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);
- }
- );
+ final Set<BigDecimal> customerRepayments = Stream.iterate(1, x -> x + 1).limit(allPlannedPayments.size() - 1).map(x ->
+ {
+ final BigDecimal costComponentSum = allPlannedPayments.get(x).getCostComponents().stream()
+ .filter(this::includeCostComponentsInSumCheck)
+ .map(CostComponent::getAmount)
+ .reduce(BigDecimal::add)
+ .orElse(BigDecimal.ZERO);
+ final BigDecimal principalDifference = allPlannedPayments.get(x-1).getRemainingPrincipal().subtract(allPlannedPayments.get(x).getRemainingPrincipal());
+ Assert.assertEquals(costComponentSum, principalDifference);
+ Assert.assertNotEquals("Remaining principle should always be positive or zero.",
+ allPlannedPayments.get(x).getRemainingPrincipal().signum(), -1);
+ return costComponentSum;
+ }
+ ).collect(Collectors.toSet());
//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());
+ final int uniqueChargeIdentifierCount = x.getCostComponents().stream()
+ .map(CostComponent::getChargeIdentifier)
+ .collect(Collectors.toSet())
+ .size();
+ Assert.assertEquals("There should be only one cost component per charge per planned payment.",
+ x.getCostComponents().size(), uniqueChargeIdentifierCount);
});
//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());
@@ -357,7 +331,7 @@
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.
+ //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());
@@ -365,6 +339,22 @@
Assert.assertEquals(testCase.expectedChargeIdentifiers, resultChargeIdentifiers);
}
+ private boolean includeCostComponentsInSumCheck(CostComponent costComponent) {
+ switch (costComponent.getChargeIdentifier()) {
+ case ChargeIdentifiers.INTEREST_ID:
+ case ChargeIdentifiers.DISBURSEMENT_FEE_ID:
+ case ChargeIdentifiers.TRACK_DISBURSAL_PAYMENT_ID:
+ case ChargeIdentifiers.LATE_FEE_ID:
+ case ChargeIdentifiers.LOAN_ORIGINATION_FEE_ID:
+ case ChargeIdentifiers.TRACK_RETURN_PRINCIPAL_ID:
+ case ChargeIdentifiers.PROCESSING_FEE_ID:
+ return true;
+ default:
+ return false;
+
+ }
+ }
+
@Test
public void getScheduledCharges() {
final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
@@ -389,7 +379,7 @@
.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 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,
@@ -409,12 +399,4 @@
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());
- }
}