Merge pull request #6 from myrlen/develop
late fees
diff --git a/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/CaseParameters.java b/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/CaseParameters.java
index 5fc2e9c..f69b387 100644
--- a/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/CaseParameters.java
+++ b/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/CaseParameters.java
@@ -103,10 +103,10 @@
if (o == null || getClass() != o.getClass()) return false;
CaseParameters that = (CaseParameters) o;
return Objects.equals(customerIdentifier, that.customerIdentifier) &&
- Objects.equals(creditWorthinessSnapshots, that.creditWorthinessSnapshots) &&
- Objects.equals(maximumBalance, that.maximumBalance) &&
- Objects.equals(termRange, that.termRange) &&
- Objects.equals(paymentCycle, that.paymentCycle);
+ Objects.equals(creditWorthinessSnapshots, that.creditWorthinessSnapshots) &&
+ Objects.equals(maximumBalance, that.maximumBalance) &&
+ Objects.equals(termRange, that.termRange) &&
+ Objects.equals(paymentCycle, that.paymentCycle);
}
@Override
@@ -117,11 +117,11 @@
@Override
public String toString() {
return "CaseParameters{" +
- "customerIdentifier='" + customerIdentifier + '\'' +
- ", creditWorthinessSnapshots=" + creditWorthinessSnapshots +
- ", maximumBalance=" + maximumBalance +
- ", termRange=" + termRange +
- ", paymentCycle=" + paymentCycle +
- '}';
+ "customerIdentifier='" + customerIdentifier + '\'' +
+ ", creditWorthinessSnapshots=" + creditWorthinessSnapshots +
+ ", maximumBalance=" + maximumBalance +
+ ", termRange=" + termRange +
+ ", paymentCycle=" + paymentCycle +
+ '}';
}
}
\ No newline at end of file
diff --git a/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanCommandEvent.java b/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanCommandEvent.java
index 2ae1249..4576d73 100644
--- a/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanCommandEvent.java
+++ b/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanCommandEvent.java
@@ -24,13 +24,15 @@
public class IndividualLoanCommandEvent {
private String productIdentifier;
private String caseIdentifier;
+ private String forDate;
public IndividualLoanCommandEvent() {
}
- public IndividualLoanCommandEvent(String productIdentifier, String caseIdentifier) {
+ public IndividualLoanCommandEvent(String productIdentifier, String caseIdentifier, String forDate) {
this.productIdentifier = productIdentifier;
this.caseIdentifier = caseIdentifier;
+ this.forDate = forDate;
}
public String getProductIdentifier() {
@@ -49,25 +51,35 @@
this.caseIdentifier = caseIdentifier;
}
+ public String getForDate() {
+ return forDate;
+ }
+
+ public void setForDate(String forDate) {
+ this.forDate = forDate;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
IndividualLoanCommandEvent that = (IndividualLoanCommandEvent) o;
return Objects.equals(productIdentifier, that.productIdentifier) &&
- Objects.equals(caseIdentifier, that.caseIdentifier);
+ Objects.equals(caseIdentifier, that.caseIdentifier) &&
+ Objects.equals(forDate, that.forDate);
}
@Override
public int hashCode() {
- return Objects.hash(productIdentifier, caseIdentifier);
+ return Objects.hash(productIdentifier, caseIdentifier, forDate);
}
@Override
public String toString() {
return "IndividualLoanCommandEvent{" +
- "productIdentifier='" + productIdentifier + '\'' +
- ", caseIdentifier='" + caseIdentifier + '\'' +
- '}';
+ "productIdentifier='" + productIdentifier + '\'' +
+ ", caseIdentifier='" + caseIdentifier + '\'' +
+ ", forDate='" + forDate + '\'' +
+ '}';
}
}
diff --git a/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanEventConstants.java b/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanEventConstants.java
index c5fa4f2..89e1e4f 100644
--- a/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanEventConstants.java
+++ b/api/src/main/java/io/mifos/individuallending/api/v1/events/IndividualLoanEventConstants.java
@@ -28,6 +28,7 @@
String DISBURSE_INDIVIDUALLOAN_CASE = "disburse-individualloan-case";
String APPLY_INTEREST_INDIVIDUALLOAN_CASE = "apply-interest-individualloan-case";
String ACCEPT_PAYMENT_INDIVIDUALLOAN_CASE = "accept-payment-individualloan-case";
+ String CHECK_LATE_INDIVIDUALLOAN_CASE = "check-late-individualloan-case";
String MARK_LATE_INDIVIDUALLOAN_CASE = "mark-late-individualloan-case";
String WRITE_OFF_INDIVIDUALLOAN_CASE = "write-off-individualloan-case";
String CLOSE_INDIVIDUALLOAN_CASE = "close-individualloan-case";
@@ -39,6 +40,7 @@
String SELECTOR_DISBURSE_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + DISBURSE_INDIVIDUALLOAN_CASE + "'";
String SELECTOR_APPLY_INTEREST_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + APPLY_INTEREST_INDIVIDUALLOAN_CASE + "'";
String SELECTOR_ACCEPT_PAYMENT_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + ACCEPT_PAYMENT_INDIVIDUALLOAN_CASE + "'";
+ String SELECTOR_CHECK_LATE_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + CHECK_LATE_INDIVIDUALLOAN_CASE + "'";
String SELECTOR_MARK_LATE_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + MARK_LATE_INDIVIDUALLOAN_CASE + "'";
String SELECTOR_WRITE_OFF_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + WRITE_OFF_INDIVIDUALLOAN_CASE + "'";
String SELECTOR_CLOSE_INDIVIDUALLOAN_CASE = SELECTOR_NAME + " = '" + CLOSE_INDIVIDUALLOAN_CASE + "'";
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java b/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
index 1adcc04..ffe36ac 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
@@ -344,6 +344,22 @@
@RequestParam(value="forpaymentsize", required = false, defaultValue = "") final BigDecimal forPaymentSize);
+
+ @RequestMapping(
+ value = "/products/{productidentifier}/cases/{caseidentifier}/actions/{actionidentifier}/costcomponents",
+ method = RequestMethod.GET,
+ produces = MediaType.ALL_VALUE,
+ consumes = MediaType.APPLICATION_JSON_VALUE
+ )
+ List<CostComponent> getCostComponentsForAction(
+ @PathVariable("productidentifier") final String productIdentifier,
+ @PathVariable("caseidentifier") final String caseIdentifier,
+ @PathVariable("actionidentifier") final String actionIdentifier,
+ @RequestParam(value="touchingaccounts", required = false, defaultValue = "") final Set<String> forAccountDesignators,
+ @RequestParam(value="forpaymentsize", required = false, defaultValue = "") final BigDecimal forPaymentSize,
+ @RequestParam(value="fordatetime", required = false, defaultValue = "") final String forDateTime);
+
+
@RequestMapping(
value = "/products/{productidentifier}/cases/{caseidentifier}/actions/{actionidentifier}/costcomponents",
method = RequestMethod.GET,
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/domain/ChargeDefinition.java b/api/src/main/java/io/mifos/portfolio/api/v1/domain/ChargeDefinition.java
index 3890867..50ffa13 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/domain/ChargeDefinition.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/domain/ChargeDefinition.java
@@ -88,6 +88,8 @@
@ValidIdentifier(optional = true)
private String toSegment;
+ private Boolean chargeOnTop;
+
public ChargeDefinition() {
}
@@ -220,6 +222,14 @@
this.toSegment = toSegment;
}
+ public Boolean getChargeOnTop() {
+ return chargeOnTop;
+ }
+
+ public void setChargeOnTop(Boolean chargeOnTop) {
+ this.chargeOnTop = chargeOnTop;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -240,12 +250,13 @@
forCycleSizeUnit == that.forCycleSizeUnit &&
Objects.equals(forSegmentSet, that.forSegmentSet) &&
Objects.equals(fromSegment, that.fromSegment) &&
- Objects.equals(toSegment, that.toSegment);
+ Objects.equals(toSegment, that.toSegment) &&
+ Objects.equals(chargeOnTop, that.chargeOnTop);
}
@Override
public int hashCode() {
- return Objects.hash(identifier, name, description, accrueAction, chargeAction, amount, chargeMethod, proportionalTo, fromAccountDesignator, accrualAccountDesignator, toAccountDesignator, forCycleSizeUnit, readOnly, forSegmentSet, fromSegment, toSegment);
+ return Objects.hash(identifier, name, description, accrueAction, chargeAction, amount, chargeMethod, proportionalTo, fromAccountDesignator, accrualAccountDesignator, toAccountDesignator, forCycleSizeUnit, readOnly, forSegmentSet, fromSegment, toSegment, chargeOnTop);
}
@Override
@@ -267,6 +278,7 @@
", forSegmentSet='" + forSegmentSet + '\'' +
", fromSegment='" + fromSegment + '\'' +
", toSegment='" + toSegment + '\'' +
+ ", chargeOnTop='" + chargeOnTop + '\'' +
'}';
}
}
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/domain/Command.java b/api/src/main/java/io/mifos/portfolio/api/v1/domain/Command.java
index cc28b11..8f102a2 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/domain/Command.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/domain/Command.java
@@ -15,8 +15,11 @@
*/
package io.mifos.portfolio.api.v1.domain;
+import io.mifos.core.lang.validation.constraints.ValidLocalDateTimeString;
+
import javax.annotation.Nullable;
import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
@@ -34,7 +37,11 @@
private BigDecimal paymentSize;
private String note;
+
+ @ValidLocalDateTimeString
+ @NotNull
private String createdOn;
+
private String createdBy;
public Command() {
diff --git a/api/src/test/java/io/mifos/portfolio/api/v1/domain/CommandTest.java b/api/src/test/java/io/mifos/portfolio/api/v1/domain/CommandTest.java
index 1372188..128f7a3 100644
--- a/api/src/test/java/io/mifos/portfolio/api/v1/domain/CommandTest.java
+++ b/api/src/test/java/io/mifos/portfolio/api/v1/domain/CommandTest.java
@@ -15,11 +15,14 @@
*/
package io.mifos.portfolio.api.v1.domain;
+import io.mifos.core.lang.DateConverter;
import io.mifos.core.test.domain.ValidationTest;
import io.mifos.core.test.domain.ValidationTestCase;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
+import java.time.Clock;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -39,6 +42,7 @@
protected Command createValidTestSubject() {
final Command ret = new Command();
ret.setOneTimeAccountAssignments(Collections.emptyList());
+ ret.setCreatedOn(DateConverter.toIsoString(LocalDateTime.now(Clock.systemUTC())));
return ret;
}
diff --git a/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java b/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java
index 4c7dfcd..8ef3fb6 100644
--- a/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java
+++ b/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java
@@ -18,6 +18,7 @@
import io.mifos.accounting.api.v1.client.LedgerManager;
import io.mifos.anubis.test.v1.TenantApplicationSecurityEnvironmentTestRule;
import io.mifos.core.api.context.AutoUserContext;
+import io.mifos.core.lang.DateConverter;
import io.mifos.core.test.fixture.TenantDataStoreContextTestRule;
import io.mifos.core.test.listener.EnableEventRecording;
import io.mifos.core.test.listener.EventRecorder;
@@ -53,9 +54,13 @@
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
/**
* @author Myrle Krantz
@@ -63,7 +68,7 @@
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
classes = {AbstractPortfolioTest.TestConfiguration.class},
- properties = {"portfolio.bookInterestAsUser=interest_user", "portfolio.bookInterestInTimeSlot=0"}
+ properties = {"portfolio.bookLateFeesAndInterestAsUser=interest_user", "portfolio.bookInterestInTimeSlot=0", "portfolio.checkForLatenessInTimeSlot=0"}
)
public class AbstractPortfolioTest extends SuiteTestEnvironment {
private static final String LOGGER_NAME = "test-logger";
@@ -188,22 +193,34 @@
final List<AccountAssignment> oneTimeAccountAssignments,
final String event,
final Case.State nextState) throws InterruptedException {
- checkStateTransfer(productIdentifier, caseIdentifier, action, oneTimeAccountAssignments, BigDecimal.ZERO, event, nextState);
+ checkStateTransfer(
+ productIdentifier,
+ caseIdentifier,
+ action,
+ LocalDateTime.now(Clock.systemUTC()),
+ oneTimeAccountAssignments,
+ BigDecimal.ZERO,
+ event,
+ midnightToday(),
+ nextState);
}
void checkStateTransfer(final String productIdentifier,
final String caseIdentifier,
final Action action,
+ final LocalDateTime actionDateTime,
final List<AccountAssignment> oneTimeAccountAssignments,
final BigDecimal paymentSize,
final String event,
+ final LocalDateTime eventDateTime,
final Case.State nextState) throws InterruptedException {
final Command command = new Command();
command.setOneTimeAccountAssignments(oneTimeAccountAssignments);
command.setPaymentSize(paymentSize);
+ command.setCreatedOn(DateConverter.toIsoString(actionDateTime));
portfolioManager.executeCaseCommand(productIdentifier, caseIdentifier, action.name(), command);
- Assert.assertTrue(eventRecorder.wait(event, new IndividualLoanCommandEvent(productIdentifier, caseIdentifier)));
+ Assert.assertTrue(eventRecorder.wait(event, new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(eventDateTime))));
final Case customerCase = portfolioManager.getCase(productIdentifier, caseIdentifier);
Assert.assertEquals(nextState.name(), customerCase.getCurrentState());
@@ -259,7 +276,9 @@
amount
);
final Set<CostComponent> setOfCostComponents = new HashSet<>(costComponents);
- final Set<CostComponent> setOfExpectedCostComponents = new HashSet<>(Arrays.asList(expectedCostComponents));
+ final Set<CostComponent> setOfExpectedCostComponents = Stream.of(expectedCostComponents)
+ .filter(x -> x.getAmount().compareTo(BigDecimal.ZERO) != 0)
+ .collect(Collectors.toSet());
Assert.assertEquals(setOfExpectedCostComponents, setOfCostComponents);
}
@@ -313,4 +332,7 @@
Assert.assertTrue(eventRecorder.wait(EventConstants.PUT_TASK_INSTANCE_EXECUTION, new TaskInstanceEvent(product.getIdentifier(), customerCase.getIdentifier(), taskDefinition.getIdentifier())));
}
+ LocalDateTime midnightToday() {
+ return LocalDateTime.now().truncatedTo(ChronoUnit.DAYS);
+ }
}
diff --git a/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java b/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java
index 595d2b4..00e74f7 100644
--- a/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java
+++ b/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java
@@ -31,9 +31,10 @@
import javax.validation.Validation;
import javax.validation.Validator;
import java.math.BigDecimal;
-import java.time.Clock;
import java.time.LocalDateTime;
import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import java.util.stream.Stream;
import static org.mockito.Matchers.argThat;
@@ -60,7 +61,7 @@
static final String TELLER_ONE_ACCOUNT_IDENTIFIER = "7352";
static final String LOAN_INTEREST_ACCRUAL_ACCOUNT_IDENTIFIER = "7810";
static final String CONSUMER_LOAN_INTEREST_ACCOUNT_IDENTIFIER = "1103";
- static final String LOANS_PAYABLE_ACCOUNT_IDENTIFIER ="8530";
+ static final String LOANS_PAYABLE_ACCOUNT_IDENTIFIER ="8690";
static final String LATE_FEE_INCOME_ACCOUNT_IDENTIFIER = "1311";
static final String LATE_FEE_ACCRUAL_ACCOUNT_IDENTIFIER = "7840";
static final String ARREARS_ALLOWANCE_ACCOUNT_IDENTIFIER = "3010";
@@ -79,13 +80,17 @@
this.account.setBalance(balance);
}
- void addAccountEntry(final String message, final double amount) {
+ synchronized void addAccountEntry(final String message, final String date, final double amount) {
final AccountEntry accountEntry = new AccountEntry();
accountEntry.setAmount(amount);
accountEntry.setMessage(message);
- accountEntry.setTransactionDate(DateConverter.toIsoString(LocalDateTime.now(Clock.systemUTC())));
+ accountEntry.setTransactionDate(date);
accountEntries.add(accountEntry);
}
+
+ synchronized List<AccountEntry> copyAccountEntries() {
+ return new ArrayList<>(accountEntries);
+ }
}
private static void makeAccountResponsive(final Account account, final LocalDateTime creationDate, final LedgerManager ledgerManagerMock) {
@@ -94,7 +99,7 @@
accountMap.put(account.getIdentifier(), accountData);
Mockito.doAnswer(new AccountEntriesStreamAnswer(accountData))
.when(ledgerManagerMock)
- .fetchAccountEntriesStream(Mockito.eq(account.getIdentifier()), Matchers.anyString(), Matchers.anyString(), Matchers.eq("ASC"));
+ .fetchAccountEntriesStream(Mockito.eq(account.getIdentifier()), Matchers.anyString(), Matchers.anyString(), AdditionalMatchers.or(Matchers.eq("DESC"), Matchers.eq("ASC")));
}
@@ -385,10 +390,12 @@
journalEntry.getCreditors().forEach(creditor ->
accountMap.get(creditor.getAccountNumber()).addAccountEntry(
journalEntry.getMessage(),
+ journalEntry.getTransactionDate(),
Double.valueOf(creditor.getAmount())));
journalEntry.getDebtors().forEach(debtor ->
accountMap.get(debtor.getAccountNumber()).addAccountEntry(
journalEntry.getMessage(),
+ journalEntry.getTransactionDate(),
Double.valueOf(debtor.getAmount())));
return null;
}
@@ -425,10 +432,20 @@
@Override
public Stream<AccountEntry> answer(final InvocationOnMock invocation) throws Throwable {
final String message = invocation.getArgumentAt(2, String.class);
- if (message != null)
- return accountData.accountEntries.stream().filter(x -> x.getMessage().equals(message));
- else
- return accountData.accountEntries.stream();
+ final String direction = invocation.getArgumentAt(3, String.class);
+ final boolean asc = direction == null || direction.equals("ASC");
+ final List<AccountEntry> accountEntries = accountData.copyAccountEntries();
+ final int entryCount = accountEntries.size();
+ final Stream<AccountEntry> orderedCorrectly = asc ?
+ IntStream.rangeClosed(1, entryCount).mapToObj(i -> accountEntries.get(entryCount - i)) :
+ accountEntries.stream();
+
+ if (message != null) {
+ return orderedCorrectly.filter(x -> x.getMessage().equals(message));
+ }
+ else {
+ return orderedCorrectly;
+ }
}
}
@@ -494,8 +511,10 @@
final String productIdentifier,
final String caseIdentifier,
final Action action) {
- final JournalEntryMatcher specifiesCorrectJournalEntry = new JournalEntryMatcher(debtors, creditors, productIdentifier, caseIdentifier, action);
- Mockito.verify(ledgerManager).createJournalEntry(AdditionalMatchers.and(argThat(isValid()), argThat(specifiesCorrectJournalEntry)));
+ final Set<Debtor> filteredDebtors = debtors.stream().filter(x -> BigDecimal.valueOf(Double.valueOf(x.getAmount())).compareTo(BigDecimal.ZERO) != 0).collect(Collectors.toSet());
+ final Set<Creditor> filteredCreditors = creditors.stream().filter(x -> BigDecimal.valueOf(Double.valueOf(x.getAmount())).compareTo(BigDecimal.ZERO) != 0).collect(Collectors.toSet());
+ final JournalEntryMatcher specifiesCorrectJournalEntry = new JournalEntryMatcher(filteredDebtors, filteredCreditors, productIdentifier, caseIdentifier, action);
+ Mockito.verify(ledgerManager, Mockito.atLeastOnce()).createJournalEntry(AdditionalMatchers.and(argThat(isValid()), argThat(specifiesCorrectJournalEntry)));
}
}
\ No newline at end of file
diff --git a/component-test/src/main/java/io/mifos/portfolio/Fixture.java b/component-test/src/main/java/io/mifos/portfolio/Fixture.java
index dc3cdfe..de8fc57 100644
--- a/component-test/src/main/java/io/mifos/portfolio/Fixture.java
+++ b/component-test/src/main/java/io/mifos/portfolio/Fixture.java
@@ -139,7 +139,7 @@
ret.setCustomerIdentifier(CUSTOMER_IDENTIFIER);
ret.setMaximumBalance(fixScale(BigDecimal.valueOf(2000L)));
- ret.setTermRange(new TermRange(ChronoUnit.MONTHS, 18));
+ ret.setTermRange(new TermRange(ChronoUnit.MONTHS, 3));
ret.setPaymentCycle(new PaymentCycle(ChronoUnit.MONTHS, 1, 1, null, null));
final CreditWorthinessSnapshot customerCreditWorthinessSnapshot = new CreditWorthinessSnapshot();
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java b/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java
index c4a9e5f..50724ed 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java
@@ -41,12 +41,11 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
+import java.time.Clock;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.*;
+import java.util.stream.IntStream;
import static io.mifos.portfolio.Fixture.MINOR_CURRENCY_UNIT_DIGITS;
@@ -91,6 +90,8 @@
@Test
public void workflowTerminatingInEarlyLoanPayoff() throws InterruptedException {
+ final LocalDateTime today = midnightToday();
+
step1CreateProduct();
step2CreateCase();
step3OpenCase();
@@ -98,13 +99,15 @@
step5Disburse(
BigDecimal.valueOf(2_000_00, MINOR_CURRENCY_UNIT_DIGITS),
UPPER_RANGE_DISBURSEMENT_FEE_ID, BigDecimal.valueOf(20_00, MINOR_CURRENCY_UNIT_DIGITS));
- step6CalculateInterestAccrual();
- step7PaybackPartialAmount(expectedCurrentBalance);
+ step6CalculateInterestAccrualAndCheckForLateness(midnightToday(), null);
+ step7PaybackPartialAmount(expectedCurrentBalance, today, 0, BigDecimal.ZERO);
step8Close();
}
@Test
public void workflowWithTwoUnequalDisbursals() throws InterruptedException {
+ final LocalDateTime today = midnightToday();
+
step1CreateProduct();
step2CreateCase();
step3OpenCase();
@@ -115,13 +118,15 @@
step5Disburse(
BigDecimal.valueOf(1_500_00, MINOR_CURRENCY_UNIT_DIGITS),
UPPER_RANGE_DISBURSEMENT_FEE_ID, BigDecimal.valueOf(15_00, MINOR_CURRENCY_UNIT_DIGITS));
- step6CalculateInterestAccrual();
- step7PaybackPartialAmount(expectedCurrentBalance);
+ step6CalculateInterestAccrualAndCheckForLateness(midnightToday(), null);
+ step7PaybackPartialAmount(expectedCurrentBalance, today, 0, BigDecimal.ZERO);
step8Close();
}
@Test
public void workflowWithTwoNearlyEqualRepayments() throws InterruptedException {
+ final LocalDateTime today = midnightToday();
+
step1CreateProduct();
step2CreateCase();
step3OpenCase();
@@ -129,10 +134,13 @@
step5Disburse(
BigDecimal.valueOf(2_000_00, MINOR_CURRENCY_UNIT_DIGITS),
UPPER_RANGE_DISBURSEMENT_FEE_ID, BigDecimal.valueOf(20_00, MINOR_CURRENCY_UNIT_DIGITS));
- step6CalculateInterestAccrual();
+ step6CalculateInterestAccrualAndCheckForLateness(midnightToday(), null);
final BigDecimal repayment1 = expectedCurrentBalance.divide(BigDecimal.valueOf(2), BigDecimal.ROUND_HALF_EVEN);
- step7PaybackPartialAmount(repayment1.setScale(MINOR_CURRENCY_UNIT_DIGITS, BigDecimal.ROUND_HALF_EVEN));
- step7PaybackPartialAmount(expectedCurrentBalance);
+ step7PaybackPartialAmount(
+ repayment1.setScale(MINOR_CURRENCY_UNIT_DIGITS, BigDecimal.ROUND_HALF_EVEN),
+ today,
+ 0, BigDecimal.ZERO);
+ step7PaybackPartialAmount(expectedCurrentBalance, today, 0, BigDecimal.ZERO);
step8Close();
}
@@ -150,6 +158,106 @@
catch (IllegalArgumentException ignored) { }
}
+ @Test
+ public void workflowWithNormalRepayment() throws InterruptedException {
+ final LocalDateTime today = midnightToday();
+
+ step1CreateProduct();
+ step2CreateCase();
+ step3OpenCase();
+ step4ApproveCase();
+ step5Disburse(
+ BigDecimal.valueOf(2_000_00, MINOR_CURRENCY_UNIT_DIGITS),
+ UPPER_RANGE_DISBURSEMENT_FEE_ID, BigDecimal.valueOf(20_00, MINOR_CURRENCY_UNIT_DIGITS));
+
+ int week = 0;
+ final List<BigDecimal> repayments = new ArrayList<>();
+ while (expectedCurrentBalance.compareTo(BigDecimal.ZERO) > 0) {
+ logger.info("Simulating week {}. Expected current balance {}.", week, expectedCurrentBalance);
+ step6CalculateInterestAndCheckForLatenessForWeek(today, week);
+ final BigDecimal nextRepaymentAmount = findNextRepaymentAmount(today, (week+1)*7);
+ repayments.add(nextRepaymentAmount);
+ step7PaybackPartialAmount(nextRepaymentAmount, today, (week+1)*7, BigDecimal.ZERO);
+ week++;
+ }
+
+ final BigDecimal minPayment = repayments.stream().min(BigDecimal::compareTo).orElseThrow(IllegalStateException::new);
+ final BigDecimal maxPayment = repayments.stream().max(BigDecimal::compareTo).orElseThrow(IllegalStateException::new);
+ final BigDecimal delta = maxPayment.subtract(minPayment).abs();
+ Assert.assertTrue("Payments are " + repayments,
+ delta.divide(maxPayment, BigDecimal.ROUND_HALF_EVEN).compareTo(BigDecimal.valueOf(0.01)) <= 0);
+
+
+ step8Close();
+ }
+
+ @Test
+ public void workflowWithOneLateRepayment() throws InterruptedException {
+ final LocalDateTime today = midnightToday();
+
+ step1CreateProduct();
+ step2CreateCase();
+ step3OpenCase();
+ step4ApproveCase();
+ step5Disburse(
+ BigDecimal.valueOf(2_000_00, MINOR_CURRENCY_UNIT_DIGITS),
+ UPPER_RANGE_DISBURSEMENT_FEE_ID, BigDecimal.valueOf(20_00, MINOR_CURRENCY_UNIT_DIGITS));
+
+ int week = 0;
+ final int weekOfLateRepayment = 3;
+ final List<BigDecimal> repayments = new ArrayList<>();
+ while (expectedCurrentBalance.compareTo(BigDecimal.ZERO) > 0) {
+ logger.info("Simulating week {}. Expected current balance {}.", week, expectedCurrentBalance);
+ if (week == weekOfLateRepayment) {
+ final BigDecimal lateFee = BigDecimal.valueOf(14_49, MINOR_CURRENCY_UNIT_DIGITS);
+ step6CalculateInterestAndCheckForLatenessForRangeOfDays(
+ today,
+ (week * 7) + 1,
+ (week + 1) * 7 + 2,
+ 8,
+ lateFee);
+ final BigDecimal nextRepaymentAmount = findNextRepaymentAmount(today, (week + 1) * 7 + 2);
+ repayments.add(nextRepaymentAmount);
+ step7PaybackPartialAmount(nextRepaymentAmount, today, (week + 1) * 7 + 2, lateFee);
+ }
+ else {
+ step6CalculateInterestAndCheckForLatenessForWeek(today, week);
+ final BigDecimal nextRepaymentAmount = findNextRepaymentAmount(today, (week + 1) * 7);
+ repayments.add(nextRepaymentAmount);
+ step7PaybackPartialAmount(nextRepaymentAmount, today, (week + 1) * 7, BigDecimal.ZERO);
+ }
+ week++;
+ }
+
+ repayments.remove(3);
+
+ final BigDecimal minPayment = repayments.stream().min(BigDecimal::compareTo).orElseThrow(IllegalStateException::new);
+ final BigDecimal maxPayment = repayments.stream().max(BigDecimal::compareTo).orElseThrow(IllegalStateException::new);
+ final BigDecimal delta = maxPayment.subtract(minPayment).abs();
+ Assert.assertTrue("Payments are " + repayments,
+ delta.divide(maxPayment, BigDecimal.ROUND_HALF_EVEN).compareTo(BigDecimal.valueOf(0.01)) <= 0);
+
+
+ step8Close();
+ }
+
+ private BigDecimal findNextRepaymentAmount(
+ final LocalDateTime referenceDate,
+ final int dayNumber) {
+ AccountingFixture.mockBalance(customerLoanAccountIdentifier, expectedCurrentBalance.negate());
+
+ final List<CostComponent> costComponentsForNextPayment = portfolioManager.getCostComponentsForAction(
+ product.getIdentifier(),
+ customerCase.getIdentifier(),
+ Action.ACCEPT_PAYMENT.name(),
+ null,
+ null,
+ DateConverter.toIsoString(referenceDate.plusDays(dayNumber)));
+ return costComponentsForNextPayment.stream().filter(x -> x.getChargeIdentifier().equals(ChargeIdentifiers.REPAYMENT_ID)).findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("return missing repayment charge."))
+ .getAmount();
+ }
+
//Create product and set charges to fixed fees.
private void step1CreateProduct() throws InterruptedException {
logger.info("step1CreateProduct");
@@ -206,8 +314,9 @@
private void step2CreateCase() throws InterruptedException {
logger.info("step2CreateCase");
- caseParameters = Fixture.createAdjustedCaseParameters(x -> {
- });
+ caseParameters = Fixture.createAdjustedCaseParameters(x ->
+ x.setPaymentCycle(new PaymentCycle(ChronoUnit.WEEKS, 1, null, null, null))
+ );
final String caseParametersAsString = new Gson().toJson(caseParameters);
customerCase = createAdjustedCase(product.getIdentifier(), x -> x.setParameters(caseParametersAsString));
@@ -316,9 +425,11 @@
product.getIdentifier(),
customerCase.getIdentifier(),
Action.DISBURSE,
+ LocalDateTime.now(Clock.systemUTC()),
Collections.singletonList(assignEntryToTeller()),
amount,
IndividualLoanEventConstants.DISBURSE_INDIVIDUALLOAN_CASE,
+ midnightToday(),
Case.State.ACTIVE);
checkNextActionsCorrect(product.getIdentifier(), customerCase.getIdentifier(), Action.APPLY_INTEREST,
Action.APPLY_INTEREST, Action.MARK_LATE, Action.ACCEPT_PAYMENT, Action.DISBURSE, Action.WRITE_OFF, Action.CLOSE);
@@ -338,17 +449,62 @@
expectedCurrentBalance = expectedCurrentBalance.add(amount);
}
+ private void step6CalculateInterestAndCheckForLatenessForWeek(
+ final LocalDateTime referenceDate,
+ final int weekNumber) throws InterruptedException {
+ step6CalculateInterestAndCheckForLatenessForRangeOfDays(
+ referenceDate,
+ (weekNumber * 7) + 1,
+ (weekNumber + 1) * 7,
+ -1,
+ null);
+ }
+
+ private void step6CalculateInterestAndCheckForLatenessForRangeOfDays(
+ final LocalDateTime referenceDate,
+ final int startInclusive,
+ final int endInclusive,
+ final int dayOfLateFee,
+ final BigDecimal calculatedLateFee) throws InterruptedException {
+ try {
+ IntStream.rangeClosed(startInclusive, endInclusive)
+ .mapToObj(referenceDate::plusDays)
+ .forEach(day -> {
+ try {
+ if (day.equals(referenceDate.plusDays(dayOfLateFee))) {
+ step6CalculateInterestAccrualAndCheckForLateness(day, calculatedLateFee);
+ }
+ else {
+ step6CalculateInterestAccrualAndCheckForLateness(day, null);
+ }
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+ catch (RuntimeException e) {
+ final Throwable cause = e.getCause();
+ if (cause != null && cause.getClass().isAssignableFrom(InterruptedException.class))
+ throw (InterruptedException)e.getCause();
+ else
+ throw e;
+ }
+ }
+
//Perform daily interest calculation.
- private void step6CalculateInterestAccrual() throws InterruptedException {
- logger.info("step6CalculateInterestAccrual");
+ private void step6CalculateInterestAccrualAndCheckForLateness(
+ final LocalDateTime forTime,
+ final BigDecimal calculatedLateFee) throws InterruptedException {
+ logger.info("step6CalculateInterestAccrualAndCheckForLateness");
final String beatIdentifier = "alignment0";
- final String midnightTimeStamp = DateConverter.toIsoString(LocalDateTime.now().truncatedTo(ChronoUnit.DAYS));
+ final String midnightTimeStamp = DateConverter.toIsoString(forTime);
AccountingFixture.mockBalance(customerLoanAccountIdentifier, expectedCurrentBalance.negate());
final BigDecimal calculatedInterest = expectedCurrentBalance.multiply(Fixture.INTEREST_RATE.divide(Fixture.ACCRUAL_PERIODS, 8, BigDecimal.ROUND_HALF_EVEN))
.setScale(MINOR_CURRENCY_UNIT_DIGITS, BigDecimal.ROUND_HALF_EVEN);
+
checkCostComponentForActionCorrect(
product.getIdentifier(),
customerCase.getIdentifier(),
@@ -356,13 +512,26 @@
Collections.singleton(AccountDesignators.CUSTOMER_LOAN),
null,
new CostComponent(ChargeIdentifiers.INTEREST_ID, calculatedInterest));
+
+ if (calculatedLateFee != null) {
+ checkCostComponentForActionCorrect(
+ product.getIdentifier(),
+ customerCase.getIdentifier(),
+ Action.MARK_LATE,
+ Collections.singleton(AccountDesignators.CUSTOMER_LOAN),
+ null,
+ new CostComponent(ChargeIdentifiers.LATE_FEE_ID, calculatedLateFee));
+ }
final BeatPublish interestBeat = new BeatPublish(beatIdentifier, midnightTimeStamp);
portfolioBeatListener.publishBeat(interestBeat);
Assert.assertTrue(this.eventRecorder.wait(io.mifos.rhythm.spi.v1.events.EventConstants.POST_PUBLISHEDBEAT,
new BeatPublishEvent(EventConstants.DESTINATION, beatIdentifier, midnightTimeStamp)));
+ Assert.assertTrue(this.eventRecorder.wait(IndividualLoanEventConstants.CHECK_LATE_INDIVIDUALLOAN_CASE,
+ new IndividualLoanCommandEvent(product.getIdentifier(), customerCase.getIdentifier(), midnightTimeStamp)));
+
Assert.assertTrue(eventRecorder.wait(IndividualLoanEventConstants.APPLY_INTEREST_INDIVIDUALLOAN_CASE,
- new IndividualLoanCommandEvent(product.getIdentifier(), customerCase.getIdentifier())));
+ new IndividualLoanCommandEvent(product.getIdentifier(), customerCase.getIdentifier(), midnightTimeStamp)));
final Case customerCaseAfterStateChange = portfolioManager.getCase(product.getIdentifier(), customerCase.getIdentifier());
@@ -385,12 +554,16 @@
expectedCurrentBalance = expectedCurrentBalance.add(calculatedInterest);
}
- private void step7PaybackPartialAmount(final BigDecimal amount) throws InterruptedException {
+ private void step7PaybackPartialAmount(
+ final BigDecimal amount,
+ final LocalDateTime referenceDate,
+ final int dayNumber,
+ final BigDecimal lateFee) throws InterruptedException {
logger.info("step7PaybackPartialAmount '{}'", amount);
AccountingFixture.mockBalance(customerLoanAccountIdentifier, expectedCurrentBalance.negate());
- final BigDecimal principal = amount.subtract(interestAccrued);
+ final BigDecimal principal = amount.subtract(interestAccrued).subtract(lateFee);
checkCostComponentForActionCorrect(
product.getIdentifier(),
@@ -400,14 +573,17 @@
amount,
new CostComponent(ChargeIdentifiers.REPAYMENT_ID, amount),
new CostComponent(ChargeIdentifiers.TRACK_RETURN_PRINCIPAL_ID, principal),
- new CostComponent(ChargeIdentifiers.INTEREST_ID, interestAccrued));
+ new CostComponent(ChargeIdentifiers.INTEREST_ID, interestAccrued),
+ new CostComponent(ChargeIdentifiers.LATE_FEE_ID, lateFee));
checkStateTransfer(
product.getIdentifier(),
customerCase.getIdentifier(),
Action.ACCEPT_PAYMENT,
+ referenceDate.plusDays(dayNumber),
Collections.singletonList(assignEntryToTeller()),
amount,
IndividualLoanEventConstants.ACCEPT_PAYMENT_INDIVIDUALLOAN_CASE,
+ midnightToday(),
Case.State.ACTIVE); //Close has to be done explicitly.
checkNextActionsCorrect(product.getIdentifier(), customerCase.getIdentifier(), Action.APPLY_INTEREST,
Action.APPLY_INTEREST, Action.MARK_LATE, Action.ACCEPT_PAYMENT, Action.DISBURSE, Action.WRITE_OFF, Action.CLOSE);
@@ -417,16 +593,20 @@
debtors.add(new Debtor(AccountingFixture.LOAN_FUNDS_SOURCE_ACCOUNT_IDENTIFIER, principal.toPlainString()));
if (interestAccrued.compareTo(BigDecimal.ZERO) != 0)
debtors.add(new Debtor(AccountingFixture.LOAN_INTEREST_ACCRUAL_ACCOUNT_IDENTIFIER, interestAccrued.toPlainString()));
+ if (lateFee.compareTo(BigDecimal.ZERO) != 0)
+ debtors.add(new Debtor(AccountingFixture.LATE_FEE_ACCRUAL_ACCOUNT_IDENTIFIER, lateFee.toPlainString()));
final Set<Creditor> creditors = new HashSet<>();
creditors.add(new Creditor(AccountingFixture.TELLER_ONE_ACCOUNT_IDENTIFIER, amount.toPlainString()));
creditors.add(new Creditor(AccountingFixture.LOANS_PAYABLE_ACCOUNT_IDENTIFIER, principal.toPlainString()));
if (interestAccrued.compareTo(BigDecimal.ZERO) != 0)
creditors.add(new Creditor(AccountingFixture.CONSUMER_LOAN_INTEREST_ACCOUNT_IDENTIFIER, interestAccrued.toPlainString()));
+ if (lateFee.compareTo(BigDecimal.ZERO) != 0)
+ creditors.add(new Creditor(AccountingFixture.LATE_FEE_INCOME_ACCOUNT_IDENTIFIER, lateFee.toPlainString()));
AccountingFixture.verifyTransfer(ledgerManager, debtors, creditors, product.getIdentifier(), customerCase.getIdentifier(), Action.ACCEPT_PAYMENT);
- expectedCurrentBalance = expectedCurrentBalance.subtract(amount);
+ expectedCurrentBalance = expectedCurrentBalance.subtract(amount).add(lateFee);
interestAccrued = BigDecimal.ZERO.setScale(MINOR_CURRENCY_UNIT_DIGITS, RoundingMode.HALF_EVEN);
}
diff --git a/component-test/src/main/java/io/mifos/portfolio/listener/IndividualLoanCaseCommandEventListener.java b/component-test/src/main/java/io/mifos/portfolio/listener/IndividualLoanCaseCommandEventListener.java
index 1e4d4d3..e198db2 100644
--- a/component-test/src/main/java/io/mifos/portfolio/listener/IndividualLoanCaseCommandEventListener.java
+++ b/component-test/src/main/java/io/mifos/portfolio/listener/IndividualLoanCaseCommandEventListener.java
@@ -99,9 +99,19 @@
}
@JmsListener(
- subscription = IndividualLoanEventConstants.DESTINATION,
- destination = IndividualLoanEventConstants.DESTINATION,
- selector = IndividualLoanEventConstants.SELECTOR_MARK_LATE_INDIVIDUALLOAN_CASE
+ subscription = IndividualLoanEventConstants.DESTINATION,
+ destination = IndividualLoanEventConstants.DESTINATION,
+ selector = IndividualLoanEventConstants.SELECTOR_CHECK_LATE_INDIVIDUALLOAN_CASE
+ )
+ public void onCheckLate(@Header(TenantHeaderFilter.TENANT_HEADER) final String tenant,
+ final String payload) {
+ this.eventRecorder.event(tenant, IndividualLoanEventConstants.CHECK_LATE_INDIVIDUALLOAN_CASE, payload, IndividualLoanCommandEvent.class);
+ }
+
+ @JmsListener(
+ subscription = IndividualLoanEventConstants.DESTINATION,
+ destination = IndividualLoanEventConstants.DESTINATION,
+ selector = IndividualLoanEventConstants.SELECTOR_MARK_LATE_INDIVIDUALLOAN_CASE
)
public void onMarkLate(@Header(TenantHeaderFilter.TENANT_HEADER) final String tenant,
final String payload) {
diff --git a/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java b/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
index 1ea1bdf..31ea45d 100644
--- a/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
+++ b/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
@@ -20,7 +20,6 @@
import io.mifos.core.lang.ServiceException;
import io.mifos.customer.api.v1.client.CustomerManager;
import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
-import io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers;
import io.mifos.individuallending.api.v1.domain.product.ChargeProportionalDesignator;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.individuallending.internal.mapper.CaseParametersMapper;
@@ -44,9 +43,11 @@
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
+import java.time.LocalDateTime;
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.AccountDesignators.*;
import static io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers.*;
@@ -165,16 +166,16 @@
trackPrincipalDisbursePayment.setAmount(BigDecimal.valueOf(100));
trackPrincipalDisbursePayment.setReadOnly(true);
- //TODO: Make payable at time of ACCEPT_PAYMENT but accrued at MARK_LATE
final ChargeDefinition lateFee = charge(
LATE_FEE_NAME,
- Action.MARK_LATE,
- BigDecimal.ONE,
+ Action.ACCEPT_PAYMENT,
+ BigDecimal.TEN,
CUSTOMER_LOAN,
LATE_FEE_INCOME);
lateFee.setAccrueAction(Action.MARK_LATE.name());
lateFee.setAccrualAccountDesignator(LATE_FEE_ACCRUAL);
- lateFee.setProportionalTo(ChargeIdentifiers.REPAYMENT_ID);
+ lateFee.setProportionalTo(ChargeProportionalDesignator.REPAYMENT_DESIGNATOR.getValue());
+ lateFee.setChargeOnTop(true);
lateFee.setReadOnly(false);
//TODO: Make multiple write off allowance charges.
@@ -347,21 +348,29 @@
final String productIdentifier,
final String caseIdentifier,
final String actionIdentifier,
+ final LocalDateTime forDateTime,
final Set<String> forAccountDesignators,
final BigDecimal forPaymentSize) {
final Action action = Action.valueOf(actionIdentifier);
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(productIdentifier, caseIdentifier, Collections.emptyList());
- final Case.State caseState = Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState());
+ final Case.State caseState = Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState());
checkActionCanBeExecuted(caseState, action);
- return costComponentService.getCostComponentsForAction(action, dataContextOfAction, forPaymentSize)
- .stream()
- .filter(costComponentEntry -> chargeReferencesAccountDesignators(costComponentEntry.getKey(), action, forAccountDesignators))
+ Stream<Map.Entry<ChargeDefinition, CostComponent>> costComponentStream = costComponentService.getCostComponentsForAction(
+ action,
+ dataContextOfAction,
+ forPaymentSize,
+ forDateTime.toLocalDate())
+ .stream();
+
+ if (!forAccountDesignators.isEmpty()) {
+ costComponentStream = costComponentStream
+ .filter(costComponentEntry -> chargeReferencesAccountDesignators(costComponentEntry.getKey(), action, forAccountDesignators));
+ }
+
+ return costComponentStream
.map(costComponentEntry -> new CostComponent(costComponentEntry.getKey().getIdentifier(), costComponentEntry.getValue().getAmount()))
- .collect(Collectors.toList())
- .stream()
- .map(x -> new CostComponent(x.getChargeIdentifier(), x.getAmount()))
- .collect(Collectors.toList());
+ .collect(Collectors.toList());
}
private boolean chargeReferencesAccountDesignators(
diff --git a/service/src/main/java/io/mifos/individuallending/internal/command/ApplyInterestCommand.java b/service/src/main/java/io/mifos/individuallending/internal/command/ApplyInterestCommand.java
index 1e658f3..5c7e3ad 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/command/ApplyInterestCommand.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/ApplyInterestCommand.java
@@ -21,10 +21,12 @@
public class ApplyInterestCommand {
private final String productIdentifier;
private final String caseIdentifier;
+ private final String forTime;
- public ApplyInterestCommand(String productIdentifier, String caseIdentifier) {
+ public ApplyInterestCommand(String productIdentifier, String caseIdentifier, String forTime) {
this.productIdentifier = productIdentifier;
this.caseIdentifier = caseIdentifier;
+ this.forTime = forTime;
}
public String getProductIdentifier() {
@@ -35,11 +37,16 @@
return caseIdentifier;
}
+ public String getForTime() {
+ return forTime;
+ }
+
@Override
public String toString() {
return "ApplyInterestCommand{" +
"productIdentifier='" + productIdentifier + '\'' +
", caseIdentifier='" + caseIdentifier + '\'' +
+ ", forTime='" + forTime + '\'' +
'}';
}
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/command/CheckLateCommand.java b/service/src/main/java/io/mifos/individuallending/internal/command/CheckLateCommand.java
new file mode 100644
index 0000000..acab032
--- /dev/null
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/CheckLateCommand.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.command;
+
+/**
+ * @author Myrle Krantz
+ */
+public class CheckLateCommand {
+ private final String productIdentifier;
+ private final String caseIdentifier;
+ private final String forTime;
+
+ public CheckLateCommand(String productIdentifier, String caseIdentifier, String forTime) {
+ this.productIdentifier = productIdentifier;
+ this.caseIdentifier = caseIdentifier;
+ this.forTime = forTime;
+ }
+
+ public String getProductIdentifier() {
+ return productIdentifier;
+ }
+
+ public String getCaseIdentifier() {
+ return caseIdentifier;
+ }
+
+ public String getForTime() {
+ return forTime;
+ }
+
+ @Override
+ public String toString() {
+ return "CheckLateCommand{" +
+ "productIdentifier='" + productIdentifier + '\'' +
+ ", caseIdentifier='" + caseIdentifier + '\'' +
+ ", forTime='" + forTime + '\'' +
+ '}';
+ }
+}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/command/MarkLateCommand.java b/service/src/main/java/io/mifos/individuallending/internal/command/MarkLateCommand.java
new file mode 100644
index 0000000..400a8ba
--- /dev/null
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/MarkLateCommand.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.command;
+
+/**
+ * @author Myrle Krantz
+ */
+public class MarkLateCommand {
+ private final String productIdentifier;
+ private final String caseIdentifier;
+ private final String forTime;
+
+ public MarkLateCommand(String productIdentifier, String caseIdentifier, String forTime) {
+ this.productIdentifier = productIdentifier;
+ this.caseIdentifier = caseIdentifier;
+ this.forTime = forTime;
+ }
+
+ public String getProductIdentifier() {
+ return productIdentifier;
+ }
+
+ public String getCaseIdentifier() {
+ return caseIdentifier;
+ }
+
+ public String getForTime() {
+ return forTime;
+ }
+
+ @Override
+ public String toString() {
+ return "MarkLateCommand{" +
+ "productIdentifier='" + productIdentifier + '\'' +
+ ", caseIdentifier='" + caseIdentifier + '\'' +
+ ", forTime='" + forTime + '\'' +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/service/src/main/java/io/mifos/individuallending/internal/command/handler/BeatPublishCommandHandler.java b/service/src/main/java/io/mifos/individuallending/internal/command/handler/BeatPublishCommandHandler.java
index af4abc5..cb63a4f 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/command/handler/BeatPublishCommandHandler.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/handler/BeatPublishCommandHandler.java
@@ -22,20 +22,33 @@
import io.mifos.core.command.internal.CommandBus;
import io.mifos.core.lang.ApplicationName;
import io.mifos.core.lang.DateConverter;
+import io.mifos.core.lang.ServiceException;
+import io.mifos.individuallending.api.v1.domain.product.AccountDesignators;
+import io.mifos.individuallending.api.v1.domain.workflow.Action;
+import io.mifos.individuallending.api.v1.events.IndividualLoanCommandEvent;
+import io.mifos.individuallending.api.v1.events.IndividualLoanEventConstants;
import io.mifos.individuallending.internal.command.ApplyInterestCommand;
+import io.mifos.individuallending.internal.command.CheckLateCommand;
+import io.mifos.individuallending.internal.command.MarkLateCommand;
+import io.mifos.individuallending.internal.service.*;
import io.mifos.portfolio.api.v1.domain.Case;
import io.mifos.portfolio.service.config.PortfolioProperties;
import io.mifos.portfolio.service.internal.command.CreateBeatPublishCommand;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
import io.mifos.portfolio.service.internal.repository.CaseRepository;
+import io.mifos.portfolio.service.internal.util.AccountingAdapter;
import io.mifos.rhythm.spi.v1.domain.BeatPublish;
import io.mifos.rhythm.spi.v1.events.BeatPublishEvent;
import io.mifos.rhythm.spi.v1.events.EventConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
+import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
@@ -46,19 +59,25 @@
public class BeatPublishCommandHandler {
private final CaseRepository caseRepository;
private final PortfolioProperties portfolioProperties;
+ private final DataContextService dataContextService;
private final ApplicationName applicationName;
private final CommandBus commandBus;
+ private final AccountingAdapter accountingAdapter;
@Autowired
public BeatPublishCommandHandler(
final CaseRepository caseRepository,
final PortfolioProperties portfolioProperties,
+ final DataContextService dataContextService,
final ApplicationName applicationName,
- final CommandBus commandBus) {
+ final CommandBus commandBus,
+ final AccountingAdapter accountingAdapter) {
this.caseRepository = caseRepository;
this.portfolioProperties = portfolioProperties;
+ this.dataContextService = dataContextService;
this.applicationName = applicationName;
this.commandBus = commandBus;
+ this.accountingAdapter = accountingAdapter;
}
@Transactional
@@ -71,11 +90,95 @@
{
final Stream<CaseEntity> activeCases = caseRepository.findByCurrentStateIn(Collections.singleton(Case.State.ACTIVE.name()));
activeCases.forEach(activeCase -> {
- final ApplyInterestCommand applyInterestCommand = new ApplyInterestCommand(activeCase.getProductIdentifier(), activeCase.getIdentifier());
+ final ApplyInterestCommand applyInterestCommand = new ApplyInterestCommand(
+ activeCase.getProductIdentifier(),
+ activeCase.getIdentifier(),
+ instance.getForTime());
commandBus.dispatch(applyInterestCommand);
});
}
+ if (portfolioProperties.getCheckForLatenessInTimeSlot() == forTime.getHour())
+ {
+ final Stream<CaseEntity> activeCases = caseRepository.findByCurrentStateIn(Collections.singleton(Case.State.ACTIVE.name()));
+ activeCases.forEach(activeCase -> {
+ final CheckLateCommand checkLateCommand = new CheckLateCommand(
+ activeCase.getProductIdentifier(),
+ activeCase.getIdentifier(),
+ instance.getForTime());
+ commandBus.dispatch(checkLateCommand);
+ });
+ }
+
return new BeatPublishEvent(applicationName.toString(), instance.getIdentifier(), instance.getForTime());
}
+
+ @Transactional
+ @CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
+ @EventEmitter(
+ selectorName = io.mifos.portfolio.api.v1.events.EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.CHECK_LATE_INDIVIDUALLOAN_CASE)
+ public IndividualLoanCommandEvent process(final CheckLateCommand command) {
+ final String productIdentifier = command.getProductIdentifier();
+ final String caseIdentifier = command.getCaseIdentifier();
+ final LocalDateTime forTime = DateConverter.fromIsoString(command.getForTime());
+ final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
+ productIdentifier, caseIdentifier, Collections.emptyList());
+
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+ final String customerLoanAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN);
+ final String lateFeeAccrualAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.LATE_FEE_ACCRUAL);
+
+ final BigDecimal currentBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier);
+ if (currentBalance.compareTo(BigDecimal.ZERO) == 0) //No late fees if the current balance is zilch.
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, command.getForTime());
+
+
+ final LocalDateTime dateOfMostRecentDisbursement =
+ accountingAdapter.getDateOfMostRecentEntryContainingMessage(customerLoanAccountIdentifier, dataContextOfAction.getMessageForCharge(Action.DISBURSE))
+ .orElseThrow(() ->
+ ServiceException.badRequest("No last disbursal date for ''{0}.{1}'' could be determined. " +
+ "Therefore it cannot be checked for lateness.", productIdentifier, caseIdentifier));
+
+ final List<Period> repaymentPeriods = ScheduledActionHelpers.generateRepaymentPeriods(
+ dateOfMostRecentDisbursement.toLocalDate(),
+ forTime.toLocalDate(),
+ dataContextOfAction.getCaseParameters())
+ .collect(Collectors.toList());
+
+ final long repaymentPeriodsBetweenBeginningAndToday = repaymentPeriods.size() - 1;
+
+ final BigDecimal expectedPaymentSum = dataContextOfAction
+ .getCaseParametersEntity()
+ .getPaymentSize()
+ .multiply(BigDecimal.valueOf(repaymentPeriodsBetweenBeginningAndToday));
+
+ final BigDecimal paymentsSum = accountingAdapter.sumMatchingEntriesSinceDate(
+ customerLoanAccountIdentifier,
+ dateOfMostRecentDisbursement.toLocalDate(),
+ dataContextOfAction.getMessageForCharge(Action.ACCEPT_PAYMENT));
+
+ final BigDecimal lateFeesAccrued = accountingAdapter.sumMatchingEntriesSinceDate(
+ lateFeeAccrualAccountIdentifier,
+ dateOfMostRecentDisbursement.toLocalDate(),
+ dataContextOfAction.getMessageForCharge(Action.MARK_LATE));
+
+ if (paymentsSum.compareTo(expectedPaymentSum.add(lateFeesAccrued)) < 0) {
+ final Optional<LocalDateTime> dateOfMostRecentLateFee = accountingAdapter.getDateOfMostRecentEntryContainingMessage(customerLoanAccountIdentifier, dataContextOfAction.getMessageForCharge(Action.MARK_LATE));
+ if (!dateOfMostRecentLateFee.isPresent() ||
+ mostRecentLateFeeIsBeforeMostRecentRepaymentPeriod(repaymentPeriods, dateOfMostRecentLateFee.get())) {
+ commandBus.dispatch(new MarkLateCommand(productIdentifier, caseIdentifier, command.getForTime()));
+ }
+ }
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, command.getForTime());
+ }
+
+ private boolean mostRecentLateFeeIsBeforeMostRecentRepaymentPeriod(
+ final List<Period> repaymentPeriods,
+ final LocalDateTime dateOfMostRecentLateFee) {
+ return repaymentPeriods.stream()
+ .anyMatch(x -> x.getBeginDate().isAfter(dateOfMostRecentLateFee.toLocalDate()));
+ }
}
\ No newline at end of file
diff --git a/service/src/main/java/io/mifos/individuallending/internal/command/handler/IndividualLoanCommandHandler.java b/service/src/main/java/io/mifos/individuallending/internal/command/handler/IndividualLoanCommandHandler.java
index 00740d5..d6e6aed 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/command/handler/IndividualLoanCommandHandler.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/handler/IndividualLoanCommandHandler.java
@@ -20,13 +20,15 @@
import io.mifos.core.command.annotation.CommandHandler;
import io.mifos.core.command.annotation.CommandLogLevel;
import io.mifos.core.command.annotation.EventEmitter;
+import io.mifos.core.lang.DateConverter;
import io.mifos.core.lang.ServiceException;
import io.mifos.individuallending.IndividualLendingPatternFactory;
-import io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers;
+import io.mifos.individuallending.api.v1.domain.product.AccountDesignators;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.individuallending.api.v1.events.IndividualLoanCommandEvent;
import io.mifos.individuallending.api.v1.events.IndividualLoanEventConstants;
import io.mifos.individuallending.internal.command.*;
+import io.mifos.individuallending.internal.repository.CaseParametersRepository;
import io.mifos.individuallending.internal.service.*;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
import io.mifos.portfolio.api.v1.domain.Case;
@@ -44,10 +46,10 @@
import javax.annotation.Nullable;
import java.math.BigDecimal;
+import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
-import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -65,6 +67,7 @@
private final CostComponentService costComponentService;
private final AccountingAdapter accountingAdapter;
private final TaskInstanceRepository taskInstanceRepository;
+ private final CaseParametersRepository caseParametersRepository;
@Autowired
public IndividualLoanCommandHandler(
@@ -72,12 +75,14 @@
final DataContextService dataContextService,
final CostComponentService costComponentService,
final AccountingAdapter accountingAdapter,
- final TaskInstanceRepository taskInstanceRepository) {
+ final TaskInstanceRepository taskInstanceRepository,
+ final CaseParametersRepository caseParametersRepository) {
this.caseRepository = caseRepository;
this.dataContextService = dataContextService;
this.costComponentService = costComponentService;
this.accountingAdapter = accountingAdapter;
this.taskInstanceRepository = taskInstanceRepository;
+ this.caseParametersRepository = caseParametersRepository;
}
@Transactional
@@ -90,7 +95,7 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.OPEN);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.OPEN);
checkIfTasksAreOutstanding(dataContextOfAction, Action.OPEN);
@@ -109,16 +114,19 @@
.map(Optional::get)
.collect(Collectors.toList());
+ final LocalDateTime today = today();
+
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
+ command.getCommand().getCreatedOn(),
dataContextOfAction.getMessageForCharge(Action.OPEN),
Action.OPEN.getTransactionType());
//Only move to new state if book charges command was accepted.
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.PENDING.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -131,7 +139,7 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DENY);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.DENY);
checkIfTasksAreOutstanding(dataContextOfAction, Action.DENY);
@@ -150,11 +158,13 @@
.map(Optional::get)
.collect(Collectors.toList());
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final LocalDateTime today = today();
+
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.CLOSED.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -167,7 +177,7 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.APPROVE);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.APPROVE);
checkIfTasksAreOutstanding(dataContextOfAction, Action.APPROVE);
@@ -178,12 +188,12 @@
designatorToAccountIdentifierMapper.getLedgersNeedingAccounts()
.map(ledger ->
new AccountAssignment(ledger.getDesignator(),
- accountingAdapter.createAccountForLedgerAssignment(dataContextOfAction.getCaseParameters().getCustomerIdentifier(), ledger)))
- .map(accountAssignment -> CaseMapper.map(accountAssignment, dataContextOfAction.getCustomerCase()))
+ accountingAdapter.createAccountForLedgerAssignment(dataContextOfAction.getCaseParametersEntity().getCustomerIdentifier(), ledger)))
+ .map(accountAssignment -> CaseMapper.map(accountAssignment, dataContextOfAction.getCustomerCaseEntity()))
.forEach(caseAccountAssignmentEntity ->
- dataContextOfAction.getCustomerCase().getAccountAssignments().add(caseAccountAssignmentEntity)
+ dataContextOfAction.getCustomerCaseEntity().getAccountAssignments().add(caseAccountAssignmentEntity)
);
- caseRepository.save(dataContextOfAction.getCustomerCase());
+ caseRepository.save(dataContextOfAction.getCustomerCaseEntity());
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
costComponentService.getCostComponentsForApprove(dataContextOfAction);
@@ -197,17 +207,20 @@
.map(Optional::get)
.collect(Collectors.toList());
+ final LocalDateTime today = today();
+
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
+ command.getCommand().getCreatedOn(),
dataContextOfAction.getMessageForCharge(Action.APPROVE),
Action.APPROVE.getTransactionType());
//Only move to new state if book charges command was accepted.
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.APPROVED.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -218,7 +231,7 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DISBURSE);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.DISBURSE);
checkIfTasksAreOutstanding(dataContextOfAction, Action.DISBURSE);
@@ -239,22 +252,34 @@
.map(Optional::get)
.collect(Collectors.toList());
+ final LocalDateTime today = today();
+
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
+ command.getCommand().getCreatedOn(),
dataContextOfAction.getMessageForCharge(Action.DISBURSE),
Action.DISBURSE.getTransactionType());
//Only move to new state if book charges command was accepted.
- if (Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()) != Case.State.ACTIVE) {
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ if (Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()) != Case.State.ACTIVE) {
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
final LocalDateTime endOfTerm
- = ScheduledActionHelpers.getRoughEndDate(LocalDate.now(ZoneId.of("UTC")), dataContextOfAction.getCaseParameters())
+ = ScheduledActionHelpers.getRoughEndDate(today.toLocalDate(), dataContextOfAction.getCaseParameters())
.atTime(LocalTime.MIDNIGHT);
customerCase.setEndOfTerm(endOfTerm);
customerCase.setCurrentState(Case.State.ACTIVE.name());
caseRepository.save(customerCase);
}
+ final String customerLoanAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN);
+ final BigDecimal currentBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier).negate();
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ final BigDecimal newLoanPaymentSize = costComponentService.getLoanPaymentSize(
+ currentBalance.add(disbursalAmount),
+ dataContextOfAction);
+
+ dataContextOfAction.getCaseParametersEntity().setPaymentSize(newLoanPaymentSize);
+ caseParametersRepository.save(dataContextOfAction.getCaseParametersEntity());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -267,9 +292,9 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, null);
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.APPLY_INTEREST);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.APPLY_INTEREST);
- if (dataContextOfAction.getCustomerCase().getEndOfTerm() == null)
+ if (dataContextOfAction.getCustomerCaseEntity().getEndOfTerm() == null)
throw ServiceException.internalError(
"End of term not set for active case ''{0}.{1}.''", productIdentifier, caseIdentifier);
@@ -289,11 +314,12 @@
.collect(Collectors.toList());
accountingAdapter.bookCharges(charges,
- "",
+ "Applied interest on " + command.getForTime(),
+ command.getForTime(),
dataContextOfAction.getMessageForCharge(Action.APPLY_INTEREST),
Action.APPLY_INTEREST.getTransactionType());
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, command.getForTime());
}
@Transactional
@@ -306,21 +332,19 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.ACCEPT_PAYMENT);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.ACCEPT_PAYMENT);
checkIfTasksAreOutstanding(dataContextOfAction, Action.ACCEPT_PAYMENT);
- if (dataContextOfAction.getCustomerCase().getEndOfTerm() == null)
+ if (dataContextOfAction.getCustomerCaseEntity().getEndOfTerm() == null)
throw ServiceException.internalError(
"End of term not set for active case ''{0}.{1}.''", productIdentifier, caseIdentifier);
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
- costComponentService.getCostComponentsForAcceptPayment(dataContextOfAction, command.getCommand().getPaymentSize());
-
- final BigDecimal sumOfAdjustments = costComponentsForRepaymentPeriod.stream()
- .filter(entry -> entry.getKey().getIdentifier().equals(ChargeIdentifiers.REPAYMENT_ID))
- .map(entry -> entry.getValue().getAmount())
- .reduce(BigDecimal.ZERO, BigDecimal::add);
+ costComponentService.getCostComponentsForAcceptPayment(
+ dataContextOfAction,
+ command.getCommand().getPaymentSize(),
+ DateConverter.fromIsoString(command.getCommand().getCreatedOn()).toLocalDate());
final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
= new DesignatorToAccountIdentifierMapper(dataContextOfAction);
@@ -334,13 +358,59 @@
.map(Optional::get)
.collect(Collectors.toList());
+ final LocalDateTime today = today();
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
+ command.getCommand().getCreatedOn(),
dataContextOfAction.getMessageForCharge(Action.ACCEPT_PAYMENT),
Action.ACCEPT_PAYMENT.getTransactionType());
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
+ }
+
+ @Transactional
+ @CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
+ @EventEmitter(
+ selectorName = EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.MARK_LATE_INDIVIDUALLOAN_CASE)
+ public IndividualLoanCommandEvent process(final MarkLateCommand command) {
+ final String productIdentifier = command.getProductIdentifier();
+ final String caseIdentifier = command.getCaseIdentifier();
+ final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
+ productIdentifier, caseIdentifier, Collections.emptyList());
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.MARK_LATE);
+
+ checkIfTasksAreOutstanding(dataContextOfAction, Action.MARK_LATE);
+
+ if (dataContextOfAction.getCustomerCaseEntity().getEndOfTerm() == null)
+ throw ServiceException.internalError(
+ "End of term not set for active case ''{0}.{1}.''", productIdentifier, caseIdentifier);
+
+ final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
+ costComponentService.getCostComponentsForMarkLate(dataContextOfAction, DateConverter.fromIsoString(command.getForTime()));
+
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+
+ final List<ChargeInstance> charges = costComponentsForRepaymentPeriod.stream()
+ .map(entry -> mapCostComponentEntryToChargeInstance(
+ Action.MARK_LATE,
+ entry,
+ designatorToAccountIdentifierMapper))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList());
+
+ final LocalDateTime today = today();
+
+ accountingAdapter.bookCharges(charges,
+ "Marked late on " + command.getForTime(),
+ command.getForTime(),
+ dataContextOfAction.getMessageForCharge(Action.MARK_LATE),
+ Action.MARK_LATE.getTransactionType());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -351,14 +421,17 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.WRITE_OFF);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.WRITE_OFF);
checkIfTasksAreOutstanding(dataContextOfAction, Action.WRITE_OFF);
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final LocalDateTime today = today();
+
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.CLOSED.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -369,7 +442,7 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.CLOSE);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.CLOSE);
checkIfTasksAreOutstanding(dataContextOfAction, Action.CLOSE);
@@ -389,15 +462,19 @@
.map(Optional::get)
.collect(Collectors.toList());
+ final LocalDateTime today = today();
+
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
+ command.getCommand().getCreatedOn(),
dataContextOfAction.getMessageForCharge(Action.DISBURSE),
Action.DISBURSE.getTransactionType());
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.CLOSED.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
@Transactional
@@ -408,14 +485,17 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = dataContextService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.RECOVER);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCaseEntity().getCurrentState()), Action.RECOVER);
checkIfTasksAreOutstanding(dataContextOfAction, Action.RECOVER);
- final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final LocalDateTime today = today();
+
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCaseEntity();
customerCase.setCurrentState(Case.State.CLOSED.name());
caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, DateConverter.toIsoString(today));
}
private static Optional<ChargeInstance> mapCostComponentEntryToChargeInstance(
@@ -461,12 +541,16 @@
}
private void checkIfTasksAreOutstanding(final DataContextOfAction dataContextOfAction, final Action action) {
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final String caseIdentifier = dataContextOfAction.getCustomerCase().getIdentifier();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final String caseIdentifier = dataContextOfAction.getCustomerCaseEntity().getIdentifier();
final boolean tasksOutstanding = taskInstanceRepository.areTasksOutstanding(
productIdentifier, caseIdentifier, action.name());
if (tasksOutstanding)
throw ServiceException.conflict("Cannot execute action ''{0}'' for case ''{1}.{2}'' because tasks are incomplete.",
action.name(), productIdentifier, caseIdentifier);
}
+
+ private static LocalDateTime today() {
+ return LocalDate.now(Clock.systemUTC()).atStartOfDay();
+ }
}
\ No newline at end of file
diff --git a/service/src/main/java/io/mifos/individuallending/internal/mapper/CaseParametersMapper.java b/service/src/main/java/io/mifos/individuallending/internal/mapper/CaseParametersMapper.java
index 1655266..bac7bdd 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/mapper/CaseParametersMapper.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/mapper/CaseParametersMapper.java
@@ -34,7 +34,9 @@
*/
public class CaseParametersMapper {
- public static CaseParametersEntity map(final Long caseId, final CaseParameters instance) {
+ public static CaseParametersEntity map(
+ final Long caseId,
+ final CaseParameters instance) {
final CaseParametersEntity ret = new CaseParametersEntity();
ret.setCaseId(caseId);
@@ -49,6 +51,7 @@
ret.setPaymentCycleAlignmentWeek(instance.getPaymentCycle().getAlignmentWeek());
ret.setPaymentCycleAlignmentMonth(instance.getPaymentCycle().getAlignmentMonth());
ret.setCreditWorthinessFactors(mapSnapshotsToFactors(instance.getCreditWorthinessSnapshots(), ret));
+ ret.setPaymentSize(BigDecimal.ONE.negate()); //semaphore for not yet set.
return ret;
}
@@ -56,6 +59,8 @@
public static Set<CaseCreditWorthinessFactorEntity> mapSnapshotsToFactors(
final List<CreditWorthinessSnapshot> creditWorthinessSnapshots,
final CaseParametersEntity caseParametersEntity) {
+ if (creditWorthinessSnapshots == null)
+ return Collections.emptySet();
return Stream.iterate(0, i -> i+1).limit(creditWorthinessSnapshots.size())
.flatMap(i -> mapSnapshotToFactors(
creditWorthinessSnapshots.get(i), i, caseParametersEntity)).collect(Collectors.toSet());
diff --git a/service/src/main/java/io/mifos/individuallending/internal/repository/CaseParametersEntity.java b/service/src/main/java/io/mifos/individuallending/internal/repository/CaseParametersEntity.java
index b9ecf6b..00d6176 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/repository/CaseParametersEntity.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/repository/CaseParametersEntity.java
@@ -70,6 +70,9 @@
@OneToMany(targetEntity = CaseCreditWorthinessFactorEntity.class, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "caseId")
private Set<CaseCreditWorthinessFactorEntity> creditWorthinessFactors;
+ @Column(name = "payment_size")
+ private BigDecimal paymentSize;
+
public CaseParametersEntity() {
}
@@ -177,6 +180,14 @@
this.creditWorthinessFactors = creditWorthinessFactors;
}
+ public BigDecimal getPaymentSize() {
+ return paymentSize;
+ }
+
+ public void setPaymentSize(BigDecimal paymentSize) {
+ this.paymentSize = paymentSize;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
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 c3ba820..120378b 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
@@ -16,10 +16,10 @@
package io.mifos.individuallending.internal.service;
import io.mifos.core.lang.ServiceException;
-import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
import io.mifos.individuallending.api.v1.domain.product.AccountDesignators;
import io.mifos.individuallending.api.v1.domain.product.ChargeProportionalDesignator;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
+import io.mifos.individuallending.internal.repository.CaseParametersEntity;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.service.internal.util.AccountingAdapter;
@@ -61,7 +61,8 @@
public CostComponentsForRepaymentPeriod getCostComponentsForAction(
final Action action,
final DataContextOfAction dataContextOfAction,
- final BigDecimal forPaymentSize) {
+ final BigDecimal forPaymentSize,
+ final LocalDate forDate) {
switch (action) {
case OPEN:
return getCostComponentsForOpen(dataContextOfAction);
@@ -74,11 +75,11 @@
case APPLY_INTEREST:
return getCostComponentsForApplyInterest(dataContextOfAction);
case ACCEPT_PAYMENT:
- return getCostComponentsForAcceptPayment(dataContextOfAction, forPaymentSize);
+ return getCostComponentsForAcceptPayment(dataContextOfAction, forPaymentSize, forDate);
case CLOSE:
return getCostComponentsForClose(dataContextOfAction);
case MARK_LATE:
- return getCostComponentsForMarkLate(dataContextOfAction);
+ return getCostComponentsForMarkLate(dataContextOfAction, today().atStartOfDay());
case WRITE_OFF:
return getCostComponentsForWriteOff(dataContextOfAction);
case RECOVER:
@@ -89,9 +90,9 @@
}
public CostComponentsForRepaymentPeriod getCostComponentsForOpen(final DataContextOfAction dataContextOfAction) {
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.OPEN, today()));
final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(
productIdentifier, scheduledActions);
@@ -99,7 +100,7 @@
return getCostComponentsForScheduledCharges(
Collections.emptyMap(),
scheduledCharges,
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
BigDecimal.ZERO,
BigDecimal.ZERO,
dataContextOfAction.getInterest(),
@@ -108,9 +109,9 @@
}
public CostComponentsForRepaymentPeriod getCostComponentsForDeny(final DataContextOfAction dataContextOfAction) {
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.DENY, today()));
final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(
productIdentifier, scheduledActions);
@@ -118,7 +119,7 @@
return getCostComponentsForScheduledCharges(
Collections.emptyMap(),
scheduledCharges,
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
BigDecimal.ZERO,
BigDecimal.ZERO,
dataContextOfAction.getInterest(),
@@ -128,9 +129,9 @@
public CostComponentsForRepaymentPeriod getCostComponentsForApprove(final DataContextOfAction dataContextOfAction) {
//Charge the approval fee if applicable.
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.APPROVE, today()));
final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(
productIdentifier, scheduledActions);
@@ -138,7 +139,7 @@
return getCostComponentsForScheduledCharges(
Collections.emptyMap(),
scheduledCharges,
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
BigDecimal.ZERO,
BigDecimal.ZERO,
dataContextOfAction.getInterest(),
@@ -155,21 +156,21 @@
final BigDecimal currentBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier).negate();
if (requestedDisbursalSize != null &&
- dataContextOfAction.getCaseParameters().getMaximumBalance().compareTo(
+ dataContextOfAction.getCaseParametersEntity().getBalanceRangeMaximum().compareTo(
currentBalance.add(requestedDisbursalSize)) < 0)
throw ServiceException.conflict("Cannot disburse over the maximum balance.");
final Optional<LocalDateTime> optionalStartOfTerm = accountingAdapter.getDateOfOldestEntryContainingMessage(
customerLoanAccountIdentifier,
dataContextOfAction.getMessageForCharge(Action.DISBURSE));
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.DISBURSE, today()));
final BigDecimal disbursalSize;
if (requestedDisbursalSize == null)
- disbursalSize = dataContextOfAction.getCaseParameters().getMaximumBalance().negate();
+ disbursalSize = dataContextOfAction.getCaseParametersEntity().getBalanceRangeMaximum().negate();
else
disbursalSize = requestedDisbursalSize.negate();
@@ -195,7 +196,7 @@
return getCostComponentsForScheduledCharges(
accruedCostComponents,
chargesSplitIntoScheduledAndAccrued.get(false),
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
currentBalance,
disbursalSize,
dataContextOfAction.getInterest(),
@@ -213,9 +214,9 @@
final LocalDate startOfTerm = getStartOfTermOrThrow(dataContextOfAction, customerLoanAccountIdentifier);
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final LocalDate today = today();
final ScheduledAction interestAction = new ScheduledAction(Action.APPLY_INTEREST, today, new Period(1, today));
@@ -235,7 +236,7 @@
return getCostComponentsForScheduledCharges(
accruedCostComponents,
chargesSplitIntoScheduledAndAccrued.get(false),
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
currentBalance,
BigDecimal.ZERO,
dataContextOfAction.getInterest(),
@@ -245,7 +246,8 @@
public CostComponentsForRepaymentPeriod getCostComponentsForAcceptPayment(
final DataContextOfAction dataContextOfAction,
- final @Nullable BigDecimal requestedLoanPaymentSize)
+ final @Nullable BigDecimal requestedLoanPaymentSize,
+ final LocalDate forDate)
{
final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
= new DesignatorToAccountIdentifierMapper(dataContextOfAction);
@@ -254,33 +256,17 @@
final LocalDate startOfTerm = getStartOfTermOrThrow(dataContextOfAction, customerLoanAccountIdentifier);
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final ScheduledAction scheduledAction
= ScheduledActionHelpers.getNextScheduledPayment(
startOfTerm,
- dataContextOfAction.getCustomerCase().getEndOfTerm().toLocalDate(),
- caseParameters
+ forDate,
+ dataContextOfAction.getCustomerCaseEntity().getEndOfTerm().toLocalDate(),
+ dataContextOfAction.getCaseParameters()
);
- final BigDecimal loanPaymentSize;
- if (requestedLoanPaymentSize != null)
- loanPaymentSize = requestedLoanPaymentSize;
- else {
- final List<ScheduledAction> hypotheticalScheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(
- today(),
- caseParameters);
- final List<ScheduledCharge> hypotheticalScheduledCharges = scheduledChargesService.getScheduledCharges(
- productIdentifier,
- hypotheticalScheduledActions);
- loanPaymentSize = getLoanPaymentSize(
- currentBalance,
- dataContextOfAction.getInterest(),
- minorCurrencyUnitDigits,
- hypotheticalScheduledCharges);
- }
-
final List<ScheduledCharge> scheduledChargesForThisAction = scheduledChargesService.getScheduledCharges(
productIdentifier,
Collections.singletonList(scheduledAction));
@@ -295,11 +281,33 @@
chargeDefinition -> getAccruedCostComponentToApply(dataContextOfAction, designatorToAccountIdentifierMapper, startOfTerm, chargeDefinition)));
+ final BigDecimal loanPaymentSize;
+
+ if (requestedLoanPaymentSize != null) {
+ loanPaymentSize = requestedLoanPaymentSize;
+ }
+ else {
+ if (scheduledAction.actionPeriod != null && scheduledAction.actionPeriod.isLastPeriod()) {
+ loanPaymentSize = currentBalance;
+ }
+ else {
+ final BigDecimal paymentSizeBeforeOnTopCharges = currentBalance.min(dataContextOfAction.getCaseParametersEntity().getPaymentSize());
+
+ @SuppressWarnings("UnnecessaryLocalVariable")
+ final BigDecimal paymentSizeIncludingOnTopCharges = accruedCostComponents.entrySet().stream()
+ .filter(entry -> entry.getKey().getChargeOnTop() != null && entry.getKey().getChargeOnTop())
+ .map(entry -> entry.getValue().getAmount())
+ .reduce(paymentSizeBeforeOnTopCharges, BigDecimal::add);
+
+ loanPaymentSize = paymentSizeIncludingOnTopCharges;
+ }
+ }
+
return getCostComponentsForScheduledCharges(
accruedCostComponents,
chargesSplitIntoScheduledAndAccrued.get(false),
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
currentBalance,
loanPaymentSize,
dataContextOfAction.getInterest(),
@@ -307,50 +315,6 @@
true);
}
- private static boolean isAccruedChargeForAction(final ChargeDefinition chargeDefinition, final Action action) {
- return chargeDefinition.getAccrueAction() != null &&
- chargeDefinition.getChargeAction().equals(action.name());
- }
-
- private static boolean isAccrualChargeForAction(final ChargeDefinition chargeDefinition, final Action action) {
- return chargeDefinition.getAccrueAction() != null &&
- chargeDefinition.getAccrueAction().equals(action.name());
- }
-
- private CostComponent getAccruedCostComponentToApply(final DataContextOfAction dataContextOfAction,
- final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper,
- final LocalDate startOfTerm,
- final ChargeDefinition chargeDefinition) {
- final CostComponent ret = new CostComponent();
-
- final String accrualAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getAccrualAccountDesignator());
-
- final BigDecimal amountAccrued = accountingAdapter.sumMatchingEntriesSinceDate(
- accrualAccountIdentifier,
- startOfTerm,
- dataContextOfAction.getMessageForCharge(Action.valueOf(chargeDefinition.getAccrueAction())));
- final BigDecimal amountApplied = accountingAdapter.sumMatchingEntriesSinceDate(
- accrualAccountIdentifier,
- startOfTerm,
- dataContextOfAction.getMessageForCharge(Action.valueOf(chargeDefinition.getChargeAction())));
-
- ret.setChargeIdentifier(chargeDefinition.getIdentifier());
- ret.setAmount(amountAccrued.subtract(amountApplied));
- return ret;
- }
-
- private LocalDate getStartOfTermOrThrow(final DataContextOfAction dataContextOfAction,
- final String customerLoanAccountIdentifier) {
- final Optional<LocalDateTime> firstDisbursalDateTime = accountingAdapter.getDateOfOldestEntryContainingMessage(
- customerLoanAccountIdentifier,
- dataContextOfAction.getMessageForCharge(Action.DISBURSE));
-
- return firstDisbursalDateTime.map(LocalDateTime::toLocalDate)
- .orElseThrow(() -> ServiceException.internalError(
- "Start of term for loan ''{0}'' could not be acquired from accounting.",
- dataContextOfAction.getCompoundIdentifer()));
- }
-
public CostComponentsForRepaymentPeriod getCostComponentsForClose(final DataContextOfAction dataContextOfAction) {
final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
= new DesignatorToAccountIdentifierMapper(dataContextOfAction);
@@ -361,9 +325,9 @@
final LocalDate startOfTerm = getStartOfTermOrThrow(dataContextOfAction, customerLoanAccountIdentifier);
- final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
- final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final LocalDate today = today();
final ScheduledAction closeAction = new ScheduledAction(Action.CLOSE, today, new Period(1, today));
@@ -383,7 +347,7 @@
return getCostComponentsForScheduledCharges(
accruedCostComponents,
chargesSplitIntoScheduledAndAccrued.get(false),
- caseParameters.getMaximumBalance(),
+ caseParameters.getBalanceRangeMaximum(),
currentBalance,
BigDecimal.ZERO,
dataContextOfAction.getInterest(),
@@ -391,8 +355,45 @@
true);
}
- private CostComponentsForRepaymentPeriod getCostComponentsForMarkLate(final DataContextOfAction dataContextOfAction) {
- return null;
+ public CostComponentsForRepaymentPeriod getCostComponentsForMarkLate(final DataContextOfAction dataContextOfAction,
+ final LocalDateTime forTime) {
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+ final String customerLoanAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN);
+ final BigDecimal currentBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier).negate();
+
+ final LocalDate startOfTerm = getStartOfTermOrThrow(dataContextOfAction, customerLoanAccountIdentifier);
+
+ final CaseParametersEntity caseParameters = dataContextOfAction.getCaseParametersEntity();
+ final String productIdentifier = dataContextOfAction.getProductEntity().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
+ final ScheduledAction scheduledAction = new ScheduledAction(Action.MARK_LATE, forTime.toLocalDate());
+
+ final BigDecimal loanPaymentSize = dataContextOfAction.getCaseParametersEntity().getPaymentSize();
+
+ final List<ScheduledCharge> scheduledChargesForThisAction = scheduledChargesService.getScheduledCharges(
+ productIdentifier,
+ Collections.singletonList(scheduledAction));
+
+ final Map<Boolean, List<ScheduledCharge>> chargesSplitIntoScheduledAndAccrued = scheduledChargesForThisAction.stream()
+ .collect(Collectors.partitioningBy(x -> isAccruedChargeForAction(x.getChargeDefinition(), Action.MARK_LATE)));
+
+ final Map<ChargeDefinition, CostComponent> accruedCostComponents = chargesSplitIntoScheduledAndAccrued.get(true)
+ .stream()
+ .map(ScheduledCharge::getChargeDefinition)
+ .collect(Collectors.toMap(chargeDefinition -> chargeDefinition,
+ chargeDefinition -> getAccruedCostComponentToApply(dataContextOfAction, designatorToAccountIdentifierMapper, startOfTerm, chargeDefinition)));
+
+
+ return getCostComponentsForScheduledCharges(
+ accruedCostComponents,
+ chargesSplitIntoScheduledAndAccrued.get(false),
+ caseParameters.getBalanceRangeMaximum(),
+ currentBalance,
+ loanPaymentSize,
+ dataContextOfAction.getInterest(),
+ minorCurrencyUnitDigits,
+ true);
}
private CostComponentsForRepaymentPeriod getCostComponentsForWriteOff(final DataContextOfAction dataContextOfAction) {
@@ -494,6 +495,8 @@
case REPAYMENT_DESIGNATOR:
return loanPaymentSize;
case PRINCIPAL_ADJUSTMENT_DESIGNATOR: {
+ if (loanPaymentSize.compareTo(BigDecimal.ZERO) <= 0)
+ return loanPaymentSize.abs();
final BigDecimal newRunningBalance
= runningBalance.subtract(balanceAdjustments.getOrDefault(AccountDesignators.CUSTOMER_LOAN, BigDecimal.ZERO));
final BigDecimal newLoanPaymentSize = loanPaymentSize.min(newRunningBalance);
@@ -534,6 +537,22 @@
}
}
+ public BigDecimal getLoanPaymentSize(
+ final BigDecimal assumedBalance,
+ final DataContextOfAction dataContextOfAction) {
+ final List<ScheduledAction> hypotheticalScheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(
+ today(),
+ dataContextOfAction.getCaseParameters());
+ final List<ScheduledCharge> hypotheticalScheduledCharges = scheduledChargesService.getScheduledCharges(
+ dataContextOfAction.getProductEntity().getIdentifier(),
+ hypotheticalScheduledActions);
+ return getLoanPaymentSize(
+ assumedBalance,
+ dataContextOfAction.getInterest(),
+ dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits(),
+ hypotheticalScheduledCharges);
+ }
+
static BigDecimal getLoanPaymentSize(final BigDecimal startingBalance,
final BigDecimal interest,
final int minorCurrencyUnitDigits,
@@ -595,6 +614,50 @@
return chargeDefinition.getAccrualAccountDesignator() != null;
}
+ private static boolean isAccruedChargeForAction(final ChargeDefinition chargeDefinition, final Action action) {
+ return chargeDefinition.getAccrueAction() != null &&
+ chargeDefinition.getChargeAction().equals(action.name());
+ }
+
+ private static boolean isAccrualChargeForAction(final ChargeDefinition chargeDefinition, final Action action) {
+ return chargeDefinition.getAccrueAction() != null &&
+ chargeDefinition.getAccrueAction().equals(action.name());
+ }
+
+ private CostComponent getAccruedCostComponentToApply(final DataContextOfAction dataContextOfAction,
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper,
+ final LocalDate startOfTerm,
+ final ChargeDefinition chargeDefinition) {
+ final CostComponent ret = new CostComponent();
+
+ final String accrualAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getAccrualAccountDesignator());
+
+ final BigDecimal amountAccrued = accountingAdapter.sumMatchingEntriesSinceDate(
+ accrualAccountIdentifier,
+ startOfTerm,
+ dataContextOfAction.getMessageForCharge(Action.valueOf(chargeDefinition.getAccrueAction())));
+ final BigDecimal amountApplied = accountingAdapter.sumMatchingEntriesSinceDate(
+ accrualAccountIdentifier,
+ startOfTerm,
+ dataContextOfAction.getMessageForCharge(Action.valueOf(chargeDefinition.getChargeAction())));
+
+ ret.setChargeIdentifier(chargeDefinition.getIdentifier());
+ ret.setAmount(amountAccrued.subtract(amountApplied));
+ return ret;
+ }
+
+ private LocalDate getStartOfTermOrThrow(final DataContextOfAction dataContextOfAction,
+ final String customerLoanAccountIdentifier) {
+ final Optional<LocalDateTime> firstDisbursalDateTime = accountingAdapter.getDateOfOldestEntryContainingMessage(
+ customerLoanAccountIdentifier,
+ dataContextOfAction.getMessageForCharge(Action.DISBURSE));
+
+ return firstDisbursalDateTime.map(LocalDateTime::toLocalDate)
+ .orElseThrow(() -> ServiceException.internalError(
+ "Start of term for loan ''{0}'' could not be acquired from accounting.",
+ dataContextOfAction.getCompoundIdentifer()));
+ }
+
private static LocalDate today() {
return LocalDate.now(Clock.systemUTC());
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentsForRepaymentPeriod.java b/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentsForRepaymentPeriod.java
index cb7938a..83372f4 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentsForRepaymentPeriod.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/CostComponentsForRepaymentPeriod.java
@@ -41,7 +41,8 @@
}
public Stream<Map.Entry<ChargeDefinition, CostComponent>> stream() {
- return costComponents.entrySet().stream();
+ return costComponents.entrySet().stream()
+ .filter(costComponentEntry -> costComponentEntry.getValue().getAmount().compareTo(BigDecimal.ZERO) != 0);
}
BigDecimal getBalanceAdjustment() {
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/DataContextOfAction.java b/service/src/main/java/io/mifos/individuallending/internal/service/DataContextOfAction.java
index 4cb7b5d..3d0e968 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/DataContextOfAction.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/DataContextOfAction.java
@@ -17,6 +17,8 @@
import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
+import io.mifos.individuallending.internal.mapper.CaseParametersMapper;
+import io.mifos.individuallending.internal.repository.CaseParametersEntity;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
import io.mifos.portfolio.service.internal.repository.ProductEntity;
@@ -33,12 +35,12 @@
public class DataContextOfAction {
private final ProductEntity product;
private final CaseEntity customerCase;
- private final CaseParameters caseParameters;
+ private final CaseParametersEntity caseParameters;
private final List<AccountAssignment> oneTimeAccountAssignments;
DataContextOfAction(final @Nonnull ProductEntity product,
final @Nonnull CaseEntity customerCase,
- final @Nonnull CaseParameters caseParameters,
+ final @Nonnull CaseParametersEntity caseParameters,
final @Nullable List<AccountAssignment> oneTimeAccountAssignments) {
this.product = product;
this.customerCase = customerCase;
@@ -46,18 +48,22 @@
this.oneTimeAccountAssignments = oneTimeAccountAssignments == null ? Collections.emptyList() : oneTimeAccountAssignments;
}
- public @Nonnull ProductEntity getProduct() {
+ public @Nonnull ProductEntity getProductEntity() {
return product;
}
- public @Nonnull CaseEntity getCustomerCase() {
+ public @Nonnull CaseEntity getCustomerCaseEntity() {
return customerCase;
}
- public @Nonnull CaseParameters getCaseParameters() {
+ public @Nonnull CaseParametersEntity getCaseParametersEntity() {
return caseParameters;
}
+ public @Nonnull CaseParameters getCaseParameters() {
+ return CaseParametersMapper.mapEntity(caseParameters, product.getMinorCurrencyUnitDigits());
+ }
+
@Nonnull List<AccountAssignment> getOneTimeAccountAssignments() {
return oneTimeAccountAssignments;
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/DataContextService.java b/service/src/main/java/io/mifos/individuallending/internal/service/DataContextService.java
index 225d0a6..388ae0c 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/DataContextService.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/DataContextService.java
@@ -16,8 +16,7 @@
package io.mifos.individuallending.internal.service;
import io.mifos.core.lang.ServiceException;
-import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
-import io.mifos.individuallending.internal.mapper.CaseParametersMapper;
+import io.mifos.individuallending.internal.repository.CaseParametersEntity;
import io.mifos.individuallending.internal.repository.CaseParametersRepository;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
@@ -61,9 +60,8 @@
caseRepository.findByProductIdentifierAndIdentifier(productIdentifier, caseIdentifier)
.orElseThrow(() -> ServiceException.notFound("Case not found ''{0}.{1}''.", productIdentifier, caseIdentifier));
- final CaseParameters caseParameters =
+ final CaseParametersEntity caseParameters =
caseParametersRepository.findByCaseId(customerCase.getId())
- .map(x -> CaseParametersMapper.mapEntity(x, product.getMinorCurrencyUnitDigits()))
.orElseThrow(() -> ServiceException.notFound(
"Individual loan not found ''{0}.{1}''.",
productIdentifier, caseIdentifier));
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/DesignatorToAccountIdentifierMapper.java b/service/src/main/java/io/mifos/individuallending/internal/service/DesignatorToAccountIdentifierMapper.java
index 5b2fb94..2bd9e57 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/DesignatorToAccountIdentifierMapper.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/DesignatorToAccountIdentifierMapper.java
@@ -37,8 +37,8 @@
private final @Nonnull List<AccountAssignment> oneTimeAccountAssignments;
public DesignatorToAccountIdentifierMapper(final @Nonnull DataContextOfAction dataContextOfAction) {
- this.productAccountAssignments = dataContextOfAction.getProduct().getAccountAssignments();
- this.caseAccountAssignments = dataContextOfAction.getCustomerCase().getAccountAssignments();
+ this.productAccountAssignments = dataContextOfAction.getProductEntity().getAccountAssignments();
+ this.caseAccountAssignments = dataContextOfAction.getCustomerCaseEntity().getAccountAssignments();
this.oneTimeAccountAssignments = dataContextOfAction.getOneTimeAccountAssignments();
}
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 e836004..62ddc51 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
@@ -15,9 +15,11 @@
*/
package io.mifos.individuallending.internal.service;
+import io.mifos.core.lang.ServiceException;
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.workflow.Action;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -45,21 +47,24 @@
final int pageIndex,
final int size,
final @Nonnull LocalDate initialDisbursalDate) {
- final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProductEntity().getMinorCurrencyUnitDigits();
final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(initialDisbursalDate, dataContextOfAction.getCaseParameters());
- final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(dataContextOfAction.getProduct().getIdentifier(), scheduledActions);
+ final Set<Action> actionsScheduled = scheduledActions.stream().map(x -> x.action).collect(Collectors.toSet());
+
+ final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(dataContextOfAction.getProductEntity().getIdentifier(), scheduledActions);
final BigDecimal loanPaymentSize = CostComponentService.getLoanPaymentSize(
- dataContextOfAction.getCaseParameters().getMaximumBalance(),
+ dataContextOfAction.getCaseParametersEntity().getBalanceRangeMaximum(),
dataContextOfAction.getInterest(),
minorCurrencyUnitDigits,
scheduledCharges);
final List<PlannedPayment> plannedPaymentsElements = getPlannedPaymentsElements(
- dataContextOfAction.getCaseParameters().getMaximumBalance(),
+ dataContextOfAction.getCaseParametersEntity().getBalanceRangeMaximum(),
minorCurrencyUnitDigits,
+ actionsScheduled,
scheduledCharges,
loanPaymentSize,
dataContextOfAction.getInterest());
@@ -78,6 +83,8 @@
final Set<ChargeName> chargeNames) {
final int fromIndex = size*pageIndex;
final int toIndex = Math.min(size*(pageIndex+1), plannedPaymentsElements.size());
+ if (toIndex < fromIndex)
+ throw ServiceException.badRequest("Page number ''{0}'' out of range.", pageIndex);
final List<PlannedPayment> elements = plannedPaymentsElements.subList(fromIndex, toIndex);
final PlannedPaymentPage ret = new PlannedPaymentPage();
@@ -97,15 +104,17 @@
static private List<PlannedPayment> getPlannedPaymentsElements(
final BigDecimal initialBalance,
final int minorCurrencyUnitDigits,
+ final Set<Action> actionsScheduled,
final List<ScheduledCharge> scheduledCharges,
final BigDecimal loanPaymentSize,
final BigDecimal interest) {
final Map<Period, SortedSet<ScheduledCharge>> orderedScheduledChargesGroupedByPeriod
= scheduledCharges.stream()
+ .filter(scheduledCharge -> chargeIsNotAccruedOrAccruesAtActionScheduled(actionsScheduled, scheduledCharge))
.collect(Collectors.groupingBy(IndividualLoanService::getPeriodFromScheduledCharge,
Collectors.mapping(x -> x,
Collector.of(
- () -> new TreeSet<>(new ScheduledChargesService.ScheduledChargeComparator()),
+ () -> new TreeSet<>(new ScheduledChargeComparator()),
SortedSet::add,
(left, right) -> { left.addAll(right); return left; }))));
@@ -153,6 +162,14 @@
return plannedPayments;
}
+ private static boolean chargeIsNotAccruedOrAccruesAtActionScheduled(
+ final Set<Action> actionsScheduled,
+ final ScheduledCharge scheduledCharge) {
+ // For example to prevent late charges from showing up on planned payments.
+ return scheduledCharge.getChargeDefinition().getAccrueAction() == null ||
+ actionsScheduled.contains(Action.valueOf(scheduledCharge.getChargeDefinition().getAccrueAction()));
+ }
+
private static Period getPeriodFromScheduledCharge(final ScheduledCharge scheduledCharge) {
final ScheduledAction scheduledAction = scheduledCharge.getScheduledAction();
if (ScheduledActionHelpers.actionHasNoActionPeriod(scheduledAction.action))
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 2b28eb5..0ca16cd 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
@@ -26,26 +26,36 @@
/**
* @author Myrle Krantz
*/
-class Period implements Comparable<Period> {
+public class Period implements Comparable<Period> {
final private LocalDate beginDate;
final private LocalDate endDate;
+ final private boolean lastPeriod;
Period(final LocalDate beginDate, final LocalDate endDateExclusive) {
this.beginDate = beginDate;
this.endDate = endDateExclusive;
+ this.lastPeriod = false;
+ }
+
+ Period(final LocalDate beginDate, final LocalDate endDateExclusive, final boolean lastPeriod) {
+ this.beginDate = beginDate;
+ this.endDate = endDateExclusive;
+ this.lastPeriod = lastPeriod;
}
Period(final LocalDate beginDate, final int periodLength) {
this.beginDate = beginDate;
this.endDate = beginDate.plusDays(periodLength);
+ this.lastPeriod = false;
}
Period(final int periodLength, final LocalDate endDate) {
this.beginDate = endDate.minusDays(periodLength);
this.endDate = endDate;
+ this.lastPeriod = false;
}
- LocalDate getBeginDate() {
+ public LocalDate getBeginDate() {
return beginDate;
}
@@ -53,6 +63,10 @@
return endDate;
}
+ boolean isLastPeriod() {
+ return lastPeriod;
+ }
+
String getEndDateAsString() {
return endDate == null ? null : DateConverter.toIsoString(endDate);
}
@@ -74,24 +88,33 @@
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
- Period that = (Period) o;
- return Objects.equals(beginDate, that.beginDate) &&
- Objects.equals(endDate, that.endDate);
+ Period period = (Period) o;
+ return lastPeriod == period.lastPeriod &&
+ Objects.equals(beginDate, period.beginDate) &&
+ Objects.equals(endDate, period.endDate);
}
@Override
public int hashCode() {
- return Objects.hash(beginDate, endDate);
+ return Objects.hash(beginDate, endDate, lastPeriod);
}
@Override
public int compareTo(@Nonnull Period o) {
- final int comparison = compareNullableDates(endDate, o.endDate);
-
- if (comparison == 0)
- return compareNullableDates(beginDate, o.beginDate);
- else
+ int comparison = compareNullableDates(endDate, o.endDate);
+ if (comparison != 0)
return comparison;
+
+ comparison = compareNullableDates(beginDate, o.beginDate);
+ if (comparison != 0)
+ return comparison;
+
+ if (lastPeriod == o.lastPeriod)
+ return 0;
+ else if (lastPeriod)
+ return -1;
+ else
+ return 1;
}
@SuppressWarnings("ConstantConditions")
@@ -109,8 +132,9 @@
@Override
public String toString() {
return "Period{" +
- "beginDate=" + beginDate +
- ", endDate=" + endDate +
- '}';
+ "beginDate=" + beginDate +
+ ", endDate=" + endDate +
+ ", lastPeriod=" + lastPeriod +
+ '}';
}
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/PeriodChargeCalculator.java b/service/src/main/java/io/mifos/individuallending/internal/service/PeriodChargeCalculator.java
index 91281cc..3aaebf5 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/PeriodChargeCalculator.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/PeriodChargeCalculator.java
@@ -17,7 +17,6 @@
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
-import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
@@ -31,13 +30,7 @@
/**
* @author Myrle Krantz
*/
-@SuppressWarnings("WeakerAccess")
-@Service
-public class PeriodChargeCalculator {
- public PeriodChargeCalculator()
- {
- }
-
+class PeriodChargeCalculator {
static Map<Period, BigDecimal> getPeriodAccrualInterestRate(
final BigDecimal interest,
final List<ScheduledCharge> scheduledCharges,
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledAction.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledAction.java
index 323e3f1..edcef2e 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledAction.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledAction.java
@@ -59,7 +59,7 @@
}
boolean actionIsOnOrAfter(final LocalDate date) {
- return when.compareTo(date) > 0;
+ return when.compareTo(date) >= 0;
}
@Override
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
index b9c5eb6..c6f4fc8 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
@@ -55,16 +55,16 @@
}
public static ScheduledAction getNextScheduledPayment(final @Nonnull LocalDate startOfTerm,
+ final @Nonnull LocalDate fromDate,
final @Nonnull LocalDate endOfTerm,
final @Nonnull CaseParameters caseParameters) {
- final LocalDate now = LocalDate.now(Clock.systemUTC());
- final LocalDate effectiveEndOfTerm = now.isAfter(endOfTerm) ? now : endOfTerm;
+ final LocalDate effectiveEndOfTerm = fromDate.isAfter(endOfTerm) ? fromDate : endOfTerm;
return getHypotheticalScheduledActionsForDisbursedLoan(startOfTerm, effectiveEndOfTerm, caseParameters)
.filter(x -> x.action.equals(Action.ACCEPT_PAYMENT))
- .filter(x -> x.actionIsOnOrAfter(now))
+ .filter(x -> x.actionIsOnOrAfter(fromDate))
.findFirst()
- .orElseGet(() -> new ScheduledAction(Action.ACCEPT_PAYMENT, now));
+ .orElseGet(() -> new ScheduledAction(Action.ACCEPT_PAYMENT, fromDate));
}
private static Stream<ScheduledAction> getHypotheticalScheduledActionsForDisbursedLoan(
@@ -104,10 +104,10 @@
.limit(ChronoUnit.DAYS.between(repaymentPeriod.getBeginDate(), repaymentPeriod.getEndDate()));
}
- private static Stream<Period> generateRepaymentPeriods(
- final LocalDate startOfTerm,
- final LocalDate endOfTerm,
- final CaseParameters caseParameters) {
+ public static Stream<Period> generateRepaymentPeriods(
+ final LocalDate startOfTerm,
+ final LocalDate endOfTerm,
+ final CaseParameters caseParameters) {
final List<Period> ret = new ArrayList<>();
LocalDate lastPaymentDate = startOfTerm;
@@ -119,7 +119,7 @@
lastPaymentDate = nextPaymentDate;
nextPaymentDate = generateNextPaymentDate(caseParameters, lastPaymentDate);
}
- ret.add(new Period(lastPaymentDate, nextPaymentDate));
+ ret.add(new Period(lastPaymentDate, nextPaymentDate, true));
return ret.stream();
}
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargeComparator.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargeComparator.java
new file mode 100644
index 0000000..e597b48
--- /dev/null
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargeComparator.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.api.v1.domain.product.ChargeProportionalDesignator;
+import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
+
+import java.util.Comparator;
+import java.util.Optional;
+
+/**
+ * @author Myrle Krantz
+ */
+class ScheduledChargeComparator implements Comparator<ScheduledCharge>
+{
+ @Override
+ public int compare(ScheduledCharge o1, ScheduledCharge o2) {
+ return compareScheduledCharges(o1, o2);
+ }
+
+ static int compareScheduledCharges(ScheduledCharge o1, ScheduledCharge o2) {
+ int ret = o1.getScheduledAction().when.compareTo(o2.getScheduledAction().when);
+ if (ret != 0)
+ return ret;
+
+ ret = o1.getScheduledAction().action.compareTo(o2.getScheduledAction().action);
+ if (ret != 0)
+ return ret;
+
+ ret = proportionalityApplicationOrder(o1.getChargeDefinition(), o2.getChargeDefinition());
+ if (ret != 0)
+ return ret;
+
+ return o1.getChargeDefinition().getIdentifier().compareTo(o2.getChargeDefinition().getIdentifier());
+ }
+
+ static int proportionalityApplicationOrder(final ChargeDefinition o1, final ChargeDefinition o2) {
+ final Optional<ChargeProportionalDesignator> aProportionalToDesignator
+ = ChargeProportionalDesignator.fromString(o1.getProportionalTo());
+ final Optional<ChargeProportionalDesignator> bProportionalToDesignator
+ = ChargeProportionalDesignator.fromString(o2.getProportionalTo());
+
+ if (aProportionalToDesignator.isPresent() && bProportionalToDesignator.isPresent())
+ return Integer.compare(
+ aProportionalToDesignator.get().getOrderOfApplication(),
+ bProportionalToDesignator.get().getOrderOfApplication());
+ else if (aProportionalToDesignator.isPresent())
+ return 1;
+ else if (bProportionalToDesignator.isPresent())
+ return -1;
+ else
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargesService.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargesService.java
index fcbfc6c..b578443 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargesService.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledChargesService.java
@@ -15,7 +15,6 @@
*/
package io.mifos.individuallending.internal.service;
-import io.mifos.individuallending.api.v1.domain.product.ChargeProportionalDesignator;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.service.internal.repository.BalanceSegmentEntity;
import io.mifos.portfolio.service.internal.repository.BalanceSegmentRepository;
@@ -103,6 +102,15 @@
Optional<BigDecimal> getUpperBound() {
return upperBound;
}
+
+ @Override
+ public String toString() {
+ return "Segment{" +
+ "identifier='" + identifier + '\'' +
+ ", lowerBound=" + lowerBound +
+ ", upperBound=" + upperBound +
+ '}';
+ }
}
Optional<ChargeRange> findChargeRange(final String productIdentifier, final ChargeDefinition chargeDefinition) {
@@ -151,41 +159,7 @@
accrueMapping = Stream.empty();
return Stream.concat(
- accrueMapping.sorted(ScheduledChargesService::proportionalityApplicationOrder),
- chargeMapping.sorted(ScheduledChargesService::proportionalityApplicationOrder));
- }
-
- 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)
- ret = proportionalityApplicationOrder(o1.getChargeDefinition(), o2.getChargeDefinition());
- if (ret == 0)
- return o1.getChargeDefinition().getIdentifier().compareTo(o2.getChargeDefinition().getIdentifier());
- else
- return ret;
- }
- }
-
- private static int proportionalityApplicationOrder(final ChargeDefinition o1, final ChargeDefinition o2) {
- final Optional<ChargeProportionalDesignator> aProportionalToDesignator
- = ChargeProportionalDesignator.fromString(o1.getProportionalTo());
- final Optional<ChargeProportionalDesignator> bProportionalToDesignator
- = ChargeProportionalDesignator.fromString(o2.getProportionalTo());
-
- if (aProportionalToDesignator.isPresent() && bProportionalToDesignator.isPresent())
- return Integer.compare(
- aProportionalToDesignator.get().getOrderOfApplication(),
- bProportionalToDesignator.get().getOrderOfApplication());
- else if (aProportionalToDesignator.isPresent())
- return 1;
- else if (bProportionalToDesignator.isPresent())
- return -1;
- else
- return 0;
+ accrueMapping.sorted(ScheduledChargeComparator::proportionalityApplicationOrder),
+ chargeMapping.sorted(ScheduledChargeComparator::proportionalityApplicationOrder));
}
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/config/PortfolioProperties.java b/service/src/main/java/io/mifos/portfolio/service/config/PortfolioProperties.java
index 25cf294..08beb6b 100644
--- a/service/src/main/java/io/mifos/portfolio/service/config/PortfolioProperties.java
+++ b/service/src/main/java/io/mifos/portfolio/service/config/PortfolioProperties.java
@@ -30,20 +30,23 @@
@Validated
public class PortfolioProperties {
@ValidIdentifier
- private String bookInterestAsUser;
+ private String bookLateFeesAndInterestAsUser;
@Range(min=0, max=23)
private int bookInterestInTimeSlot = 0;
+ @Range(min=0, max=23)
+ private int checkForLatenessInTimeSlot = 0;
+
public PortfolioProperties() {
}
- public String getBookInterestAsUser() {
- return bookInterestAsUser;
+ public String getBookLateFeesAndInterestAsUser() {
+ return bookLateFeesAndInterestAsUser;
}
- public void setBookInterestAsUser(String bookInterestAsUser) {
- this.bookInterestAsUser = bookInterestAsUser;
+ public void setBookLateFeesAndInterestAsUser(String bookLateFeesAndInterestAsUser) {
+ this.bookLateFeesAndInterestAsUser = bookLateFeesAndInterestAsUser;
}
public int getBookInterestInTimeSlot() {
@@ -53,4 +56,12 @@
public void setBookInterestInTimeSlot(int bookInterestInTimeSlot) {
this.bookInterestInTimeSlot = bookInterestInTimeSlot;
}
+
+ public int getCheckForLatenessInTimeSlot() {
+ return checkForLatenessInTimeSlot;
+ }
+
+ public void setCheckForLatenessInTimeSlot(int checkForLatenessInTimeSlot) {
+ this.checkForLatenessInTimeSlot = checkForLatenessInTimeSlot;
+ }
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ChargeDefinitionMapper.java b/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ChargeDefinitionMapper.java
index f99d272..d09fab1 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ChargeDefinitionMapper.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ChargeDefinitionMapper.java
@@ -57,6 +57,7 @@
ret.setFromSegment(fromSegment.getSegmentIdentifier());
ret.setToSegment(toSegment.getSegmentIdentifier());
}
+ ret.setOnTop(chargeDefinition.getChargeOnTop());
return ret;
}
@@ -82,6 +83,7 @@
ret.setFromSegment(from.getFromSegment());
ret.setToSegment(from.getToSegment());
}
+ ret.setChargeOnTop(from.getOnTop());
return ret;
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/repository/ChargeDefinitionEntity.java b/service/src/main/java/io/mifos/portfolio/service/internal/repository/ChargeDefinitionEntity.java
index 11e154e..4c9bb15 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/repository/ChargeDefinitionEntity.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/repository/ChargeDefinitionEntity.java
@@ -88,6 +88,9 @@
@Column(name = "to_segment")
private String toSegment;
+ @Column(name = "on_top")
+ private Boolean onTop;
+
public ChargeDefinitionEntity() {
}
@@ -235,6 +238,14 @@
this.toSegment = toSegment;
}
+ public Boolean getOnTop() {
+ return onTop;
+ }
+
+ public void setOnTop(Boolean onTop) {
+ this.onTop = onTop;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/service/CaseService.java b/service/src/main/java/io/mifos/portfolio/service/internal/service/CaseService.java
index c1305b8..c004eb4 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/service/CaseService.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/service/CaseService.java
@@ -35,6 +35,7 @@
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
+import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -135,12 +136,14 @@
public List<CostComponent> getActionCostComponentsForCase(final String productIdentifier,
final String caseIdentifier,
final String actionIdentifier,
+ final LocalDateTime localDateTime,
final Set<String> forAccountDesignatorsList,
final BigDecimal forPaymentSize) {
return getPatternFactoryOrThrow(productIdentifier).getCostComponentsForAction(
productIdentifier,
caseIdentifier,
actionIdentifier,
+ localDateTime,
forAccountDesignatorsList,
forPaymentSize);
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/util/AccountingAdapter.java b/service/src/main/java/io/mifos/portfolio/service/internal/util/AccountingAdapter.java
index bb15c70..4396b00 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/util/AccountingAdapter.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/util/AccountingAdapter.java
@@ -63,6 +63,7 @@
public void bookCharges(final List<ChargeInstance> costComponents,
final String note,
+ final String transactionDate,
final String message,
final String transactionType) {
final Set<Creditor> creditors = costComponents.stream()
@@ -80,7 +81,7 @@
journalEntry.setCreditors(creditors);
journalEntry.setDebtors(debtors);
journalEntry.setClerk(UserContextHolder.checkedGetUser());
- journalEntry.setTransactionDate(DateConverter.toIsoString(LocalDateTime.now()));
+ journalEntry.setTransactionDate(transactionDate);
journalEntry.setMessage(message);
journalEntry.setTransactionType(transactionType);
journalEntry.setNote(note);
@@ -101,18 +102,18 @@
.map(DateConverter::fromIsoString);
}
- public List<LocalDateTime> getDatesOfMostRecentTwoEntriesContainingMessage(final String accountIdentifier,
- final String message) {
+ public Optional<LocalDateTime> getDateOfMostRecentEntryContainingMessage(
+ final String accountIdentifier,
+ final String message) {
final Account account = ledgerManager.findAccount(accountIdentifier);
final LocalDateTime accountCreatedOn = DateConverter.fromIsoString(account.getCreatedOn());
final DateRange fromAccountCreationUntilNow = oneSidedDateRange(accountCreatedOn.toLocalDate());
return ledgerManager.fetchAccountEntriesStream(accountIdentifier, fromAccountCreationUntilNow.toString(), message, "DESC")
- .limit(2)
+ .findFirst()
.map(AccountEntry::getTransactionDate)
- .map(DateConverter::fromIsoString)
- .collect(Collectors.toList());
+ .map(DateConverter::fromIsoString);
}
public BigDecimal sumMatchingEntriesSinceDate(final String accountIdentifier, final LocalDate startDate, final String message)
diff --git a/service/src/main/java/io/mifos/portfolio/service/rest/CaseRestController.java b/service/src/main/java/io/mifos/portfolio/service/rest/CaseRestController.java
index 3117f67..a2471c2 100644
--- a/service/src/main/java/io/mifos/portfolio/service/rest/CaseRestController.java
+++ b/service/src/main/java/io/mifos/portfolio/service/rest/CaseRestController.java
@@ -19,7 +19,9 @@
import io.mifos.anubis.annotation.Permittable;
import io.mifos.core.api.util.UserContextHolder;
import io.mifos.core.command.gateway.CommandGateway;
+import io.mifos.core.lang.DateConverter;
import io.mifos.core.lang.ServiceException;
+import io.mifos.core.lang.validation.constraints.ValidLocalDateTimeString;
import io.mifos.portfolio.api.v1.PermittableGroupIds;
import io.mifos.portfolio.api.v1.domain.Case;
import io.mifos.portfolio.api.v1.domain.CasePage;
@@ -36,10 +38,13 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@@ -192,6 +197,7 @@
List<CostComponent> getCostComponentsForAction(@PathVariable("productidentifier") final String productIdentifier,
@PathVariable("caseidentifier") final String caseIdentifier,
@PathVariable("actionidentifier") final String actionIdentifier,
+ @RequestParam(value="fordatetime", required = false, defaultValue = "") final @ValidLocalDateTimeString String forDateTimeString,
@RequestParam(value="touchingaccounts", required = false, defaultValue = "") final Set<String> forAccountDesignators,
@RequestParam(value="forpaymentsize", required = false, defaultValue = "") final BigDecimal forPaymentSize)
{
@@ -200,8 +206,15 @@
if (forPaymentSize != null && forPaymentSize.compareTo(BigDecimal.ZERO) < 0)
throw ServiceException.badRequest("forpaymentsize can''t be negative.");
+ final LocalDateTime forDateTime = StringUtils.isEmpty(forDateTimeString) ? LocalDateTime.now(Clock.systemUTC()) : DateConverter.fromIsoString(forDateTimeString);
- return caseService.getActionCostComponentsForCase(productIdentifier, caseIdentifier, actionIdentifier, forAccountDesignators, forPaymentSize);
+ return caseService.getActionCostComponentsForCase(
+ productIdentifier,
+ caseIdentifier,
+ actionIdentifier,
+ forDateTime,
+ forAccountDesignators,
+ forPaymentSize);
}
@Permittable(value = AcceptedTokenType.TENANT, groupId = PermittableGroupIds.CASE_MANAGEMENT)
diff --git a/service/src/main/java/io/mifos/products/spi/PatternFactory.java b/service/src/main/java/io/mifos/products/spi/PatternFactory.java
index 4b9f3c2..dd0e555 100644
--- a/service/src/main/java/io/mifos/products/spi/PatternFactory.java
+++ b/service/src/main/java/io/mifos/products/spi/PatternFactory.java
@@ -22,6 +22,7 @@
import io.mifos.portfolio.api.v1.domain.Pattern;
import java.math.BigDecimal;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -41,6 +42,7 @@
String productIdentifier,
String caseIdentifier,
String actionIdentifier,
+ LocalDateTime forDateTime,
Set<String> forAccountDesignators,
BigDecimal forPaymentSize);
ProductCommandDispatcher getIndividualLendingCommandDispatcher();
diff --git a/service/src/main/resources/db/migrations/mariadb/V8__late_payment_determination.sql b/service/src/main/resources/db/migrations/mariadb/V8__late_payment_determination.sql
new file mode 100644
index 0000000..7d5c8c7
--- /dev/null
+++ b/service/src/main/resources/db/migrations/mariadb/V8__late_payment_determination.sql
@@ -0,0 +1,19 @@
+--
+-- Copyright 2017 Kuelap, Inc.
+--
+-- 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.
+--
+
+ALTER TABLE bastet_il_cases ADD COLUMN payment_size DECIMAL(19,4) NULL DEFAULT NULL;
+
+ALTER TABLE bastet_p_chrg_defs ADD COLUMN on_top BOOLEAN NULL DEFAULT NULL;
\ No newline at end of file
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ChargeRangeTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ChargeRangeTest.java
index a581c78..14757d7 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/ChargeRangeTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ChargeRangeTest.java
@@ -1,3 +1,18 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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 org.junit.Assert;
@@ -6,6 +21,9 @@
import java.math.BigDecimal;
import java.util.Optional;
+/**
+ * @author Myrle Krantz
+ */
public class ChargeRangeTest {
@Test
public void amountIsWithinRange() throws Exception {
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/CostComponentServiceTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/CostComponentServiceTest.java
index eab6828..f2aa460 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/CostComponentServiceTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/CostComponentServiceTest.java
@@ -1,3 +1,18 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.api.v1.domain.product.ChargeProportionalDesignator;
@@ -14,6 +29,9 @@
import static io.mifos.individuallending.api.v1.domain.product.ChargeProportionalDesignator.PRINCIPAL_ADJUSTMENT_DESIGNATOR;
import static io.mifos.individuallending.api.v1.domain.product.ChargeProportionalDesignator.RUNNING_BALANCE_DESIGNATOR;
+/**
+ * @author Myrle Krantz
+ */
@RunWith(Parameterized.class)
public class CostComponentServiceTest {
private static class TestCase {
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/Fixture.java b/service/src/test/java/io/mifos/individuallending/internal/service/Fixture.java
index 92002b7..13c7ff3 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/Fixture.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/Fixture.java
@@ -74,15 +74,15 @@
{
final List<ScheduledAction> ret = new ArrayList<>();
LocalDate begin = initial;
- for (final LocalDate paymentDate : paymentDates) {
- ret.add(scheduledRepaymentAction(begin, paymentDate));
- begin = paymentDate;
+ for (int i = 0; i < paymentDates.length; i++) {
+ ret.add(scheduledRepaymentAction(begin, paymentDates[i], (i == paymentDates.length -1)));
+ begin = paymentDates[i];
}
return ret;
}
- private static ScheduledAction scheduledRepaymentAction(final LocalDate from, final LocalDate to) {
- final Period repaymentPeriod = new Period(from, to);
+ private static ScheduledAction scheduledRepaymentAction(final LocalDate from, final LocalDate to, boolean isLast) {
+ final Period repaymentPeriod = new Period(from, to, isLast);
return new ScheduledAction(Action.ACCEPT_PAYMENT, to, repaymentPeriod, repaymentPeriod);
}
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 fc31ae7..4daaedc 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
@@ -23,6 +23,7 @@
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.*;
import io.mifos.portfolio.service.internal.repository.BalanceSegmentRepository;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
@@ -101,7 +102,8 @@
REPAYMENT_ID,
TRACK_DISBURSAL_PAYMENT_ID,
TRACK_RETURN_PRINCIPAL_ID,
- DISBURSE_PAYMENT_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
@@ -152,7 +154,7 @@
final CaseEntity customerCase = new CaseEntity();
customerCase.setInterest(interest);
- return new DataContextOfAction(product, customerCase, caseParameters, Collections.emptyList());
+ return new DataContextOfAction(product, customerCase, CaseParametersMapper.map(1L, caseParameters), Collections.emptyList());
}
@Override
@@ -313,7 +315,8 @@
.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 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)
@@ -329,6 +332,8 @@
Assert.assertEquals(valueOfPrincipleTrackingCostComponent, principalDifference);
Assert.assertNotEquals("Remaining principle should always be positive or zero.",
allPlannedPayments.get(x).getRemainingPrincipal().signum(), -1);
+ final boolean containsLateFee = allPlannedPayments.get(x).getCostComponents().stream().anyMatch(y -> y.getChargeIdentifier().equals(LATE_FEE_ID));
+ Assert.assertFalse("Late fee should not be included in planned payments", containsLateFee);
return costComponentSum;
}
).collect(Collectors.toSet());
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelpersTest.java
similarity index 91%
rename from service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java
rename to service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelpersTest.java
index 9c3b265..61771b6 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelpersTest.java
@@ -35,7 +35,7 @@
* @author Myrle Krantz
*/
@RunWith(Parameterized.class)
-public class ScheduledActionHelperTest {
+public class ScheduledActionHelpersTest {
private static class TestCase
{
final String description;
@@ -355,7 +355,7 @@
private final TestCase testCase;
- public ScheduledActionHelperTest(final TestCase testCase)
+ public ScheduledActionHelpersTest(final TestCase testCase)
{
this.testCase = testCase;
}
@@ -364,8 +364,12 @@
public void getScheduledActions() throws Exception {
final List<ScheduledAction> result = ScheduledActionHelpers.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
- Assert.assertTrue("Case " + testCase.description + " should contain " + testCase.expectedResultContents,
- result.containsAll(testCase.expectedResultContents));
+ final List<ScheduledAction> missingExpectedResults = testCase.expectedResultContents.stream()
+ .filter(expectedResult -> !result.contains(expectedResult))
+ .collect(Collectors.toList());
+
+ Assert.assertTrue("Case " + testCase.description + " missing these expected results " + missingExpectedResults,
+ missingExpectedResults.isEmpty());
result.forEach(x -> {
Assert.assertTrue(x.toString(), testCase.earliestActionDate.isBefore(x.when) || testCase.earliestActionDate.isEqual(x.when));
Assert.assertTrue(x.toString(), testCase.latestActionDate.isAfter(x.when) || testCase.latestActionDate.isEqual(x.when));
@@ -385,6 +389,31 @@
Assert.assertTrue(maximumOneInterestPerDay(result));
}
+ @Test
+ public void getNextScheduledPayment() throws Exception {
+ final LocalDate roughEndDate = ScheduledActionHelpers.getRoughEndDate(testCase.initialDisbursementDate, testCase.caseParameters);
+
+ testCase.expectedResultContents.stream()
+ .filter(x -> x.action == Action.ACCEPT_PAYMENT)
+ .forEach(expectedResultContents -> {
+ final ScheduledAction nextScheduledPayment = ScheduledActionHelpers.getNextScheduledPayment(
+ testCase.initialDisbursementDate,
+ expectedResultContents.when.minusDays(1),
+ roughEndDate,
+ testCase.caseParameters);
+ Assert.assertEquals(expectedResultContents, nextScheduledPayment);
+ });
+
+ final ScheduledAction afterAction = ScheduledActionHelpers.getNextScheduledPayment(
+ testCase.initialDisbursementDate,
+ roughEndDate.plusDays(1),
+ roughEndDate,
+ testCase.caseParameters);
+
+ Assert.assertNotNull(afterAction.actionPeriod);
+ Assert.assertTrue(afterAction.actionPeriod.isLastPeriod());
+ }
+
private long countActionsByType(final List<ScheduledAction> scheduledActions, final Action actionToCount) {
return scheduledActions.stream().filter(x -> x.action == actionToCount).count();
}
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionTest.java
index a883ee5..a287c2f 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionTest.java
@@ -28,7 +28,7 @@
final LocalDate tomorrow = today.plusDays(1);
final LocalDate yesterday = today.minusDays(1);
final ScheduledAction testSubject = new ScheduledAction(Action.APPLY_INTEREST, today);
- Assert.assertFalse(testSubject.actionIsOnOrAfter(today));
+ Assert.assertTrue(testSubject.actionIsOnOrAfter(today));
Assert.assertFalse(testSubject.actionIsOnOrAfter(tomorrow));
Assert.assertTrue(testSubject.actionIsOnOrAfter(yesterday));
}
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargeComparatorTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargeComparatorTest.java
new file mode 100644
index 0000000..2e38d67
--- /dev/null
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargeComparatorTest.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.api.v1.domain.workflow.Action;
+import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * @author Myrle Krantz
+ */
+@RunWith(Parameterized.class)
+public class ScheduledChargeComparatorTest {
+ static class TestCase {
+ private final String description;
+ ScheduledCharge a;
+ ScheduledCharge b;
+ int expected;
+
+ TestCase(String description) {
+ this.description = description;
+ }
+
+
+ TestCase setA(ScheduledCharge a) {
+ this.a = a;
+ return this;
+ }
+
+ TestCase setB(ScheduledCharge b) {
+ this.b = b;
+ return this;
+ }
+
+ TestCase setExpected(int expected) {
+ this.expected = expected;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "TestCase{" +
+ "description='" + description + '\'' +
+ ", a=" + a +
+ ", b=" + b +
+ ", expected=" + expected +
+ '}';
+ }
+ }
+
+ @Parameterized.Parameters
+ public static Collection testCases() {
+ final Collection<ScheduledChargeComparatorTest.TestCase> ret = new ArrayList<>();
+
+ final ScheduledCharge trackDisbursalScheduledCharge = new ScheduledCharge(
+ SCHEDULED_DISBURSE_ACTION,
+ TRACK_DISBURSE_CHARGE_DEFINITION,
+ Optional.empty());
+
+ final ScheduledCharge disburseFeeScheduledCharge = new ScheduledCharge(
+ SCHEDULED_DISBURSE_ACTION,
+ DISBURSE_FEE_CHARGE_DEFINITION,
+ Optional.of(new ChargeRange(BigDecimal.valueOf(1000_0000, 4), Optional.empty())));
+
+ ret.add(new TestCase("disbursementFeeVstrackDisbursement")
+ .setA(trackDisbursalScheduledCharge)
+ .setB(disburseFeeScheduledCharge)
+ .setExpected(1));
+ ret.add(new TestCase("disbursementFeeVstrackDisbursement")
+ .setA(disburseFeeScheduledCharge)
+ .setB(trackDisbursalScheduledCharge)
+ .setExpected(-1));
+ ret.add(new TestCase("disbursementFeeVstrackDisbursement")
+ .setA(disburseFeeScheduledCharge)
+ .setB(disburseFeeScheduledCharge)
+ .setExpected(0));
+
+ return ret;
+ }
+
+ private final static ScheduledAction SCHEDULED_DISBURSE_ACTION = new ScheduledAction(
+ Action.DISBURSE,
+ LocalDate.of(2017, 8, 25));
+
+ private final static ChargeDefinition TRACK_DISBURSE_CHARGE_DEFINITION = new ChargeDefinition() {{
+ this.setIdentifier("track-disburse-payment");
+ this.setName("Track disburse payment");
+ this.setDescription("Track disburse payment");
+ this.setAccrueAction(null);
+ this.setChargeAction(Action.DISBURSE.name());
+ this.setAmount(BigDecimal.valueOf(100_0000, 4));
+ this.setChargeMethod(ChargeDefinition.ChargeMethod.PROPORTIONAL);
+ this.setProportionalTo("{principaladjustment}");
+ this.setFromAccountDesignator("pending-disbursal");
+ this.setAccrualAccountDesignator(null);
+ this.setToAccountDesignator("customer-loan");
+ this.setForCycleSizeUnit(null);
+ this.setReadOnly(true);
+ this.setForSegmentSet(null);
+ this.setFromSegment(null);
+ this.setToSegment(null);
+ }};
+
+ private final static ChargeDefinition DISBURSE_FEE_CHARGE_DEFINITION = new ChargeDefinition() {{
+ this.setIdentifier("disbursement-fee2");
+ this.setName("disbursement-fee2");
+ this.setDescription("Disbursement fee");
+ this.setAccrueAction(null);
+ this.setChargeAction(Action.DISBURSE.name());
+ this.setAmount(BigDecimal.valueOf(1_0000, 4));
+ this.setChargeMethod(ChargeDefinition.ChargeMethod.PROPORTIONAL);
+ this.setProportionalTo("{principaladjustment}");
+ this.setFromAccountDesignator("'entry'");
+ this.setAccrualAccountDesignator(null);
+ this.setToAccountDesignator("disbursement-fee-income");
+ this.setForCycleSizeUnit(null);
+ this.setReadOnly(false);
+ this.setForSegmentSet("disbursement_ranges");
+ this.setFromSegment("larger");
+ this.setToSegment("larger");
+ }};
+
+ private final ScheduledChargeComparatorTest.TestCase testCase;
+
+ public ScheduledChargeComparatorTest(final ScheduledChargeComparatorTest.TestCase testCase) {
+ this.testCase = testCase;
+ }
+
+ @Test
+ public void compare() {
+ Assert.assertEquals(testCase.expected == 0, ScheduledChargeComparator.compareScheduledCharges(testCase.a, testCase.b) == 0);
+ Assert.assertEquals(testCase.expected > 0, ScheduledChargeComparator.compareScheduledCharges(testCase.a, testCase.b) > 0);
+ Assert.assertEquals(testCase.expected < 0, ScheduledChargeComparator.compareScheduledCharges(testCase.a, testCase.b) < 0);
+ }
+
+}
\ No newline at end of file
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargesServiceTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargesServiceTest.java
index 032e223..57cee42 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargesServiceTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledChargesServiceTest.java
@@ -1,3 +1,18 @@
+/*
+ * Copyright 2017 Kuelap, Inc.
+ *
+ * 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.portfolio.api.v1.domain.ChargeDefinition;
@@ -13,6 +28,9 @@
import java.math.BigDecimal;
import java.util.*;
+/**
+ * @author Myrle Krantz
+ */
@RunWith(Parameterized.class)
public class ScheduledChargesServiceTest {