Merge pull request #30 from myrle-krantz/develop
apply interest and deny
diff --git a/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/PlannedPayment.java b/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/PlannedPayment.java
index 6157f37..30c4208 100644
--- a/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/PlannedPayment.java
+++ b/api/src/main/java/io/mifos/individuallending/api/v1/domain/caseinstance/PlannedPayment.java
@@ -17,6 +17,7 @@
import io.mifos.portfolio.api.v1.domain.CostComponent;
+import javax.annotation.Nullable;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
@@ -29,7 +30,7 @@
private Double interestRate;
private List<CostComponent> costComponents;
private BigDecimal remainingPrincipal;
- private String date;
+ private @Nullable String date;
public PlannedPayment() {
}
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 72e0d50..2ae1249 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,15 +24,13 @@
public class IndividualLoanCommandEvent {
private String productIdentifier;
private String caseIdentifier;
- private String commandIdentifier;
public IndividualLoanCommandEvent() {
}
- public IndividualLoanCommandEvent(String productIdentifier, String caseIdentifier, String commandIdentifier) {
+ public IndividualLoanCommandEvent(String productIdentifier, String caseIdentifier) {
this.productIdentifier = productIdentifier;
this.caseIdentifier = caseIdentifier;
- this.commandIdentifier = commandIdentifier;
}
public String getProductIdentifier() {
@@ -51,27 +49,18 @@
this.caseIdentifier = caseIdentifier;
}
- public String getCommandIdentifier() {
- return commandIdentifier;
- }
-
- public void setCommandIdentifier(String commandIdentifier) {
- this.commandIdentifier = commandIdentifier;
- }
-
@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(commandIdentifier, that.commandIdentifier);
+ Objects.equals(caseIdentifier, that.caseIdentifier);
}
@Override
public int hashCode() {
- return Objects.hash(productIdentifier, caseIdentifier, commandIdentifier);
+ return Objects.hash(productIdentifier, caseIdentifier);
}
@Override
@@ -79,7 +68,6 @@
return "IndividualLoanCommandEvent{" +
"productIdentifier='" + productIdentifier + '\'' +
", caseIdentifier='" + caseIdentifier + '\'' +
- ", commandIdentifier='" + commandIdentifier + '\'' +
'}';
}
}
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 c76e05a..3814efe 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
@@ -201,7 +201,7 @@
void changeChargeDefinition(
@PathVariable("productidentifier") final String productIdentifier,
@PathVariable("chargedefinitionidentifier") final String chargeDefinitionIdentifier,
- final ChargeDefinition taskDefinition);
+ final ChargeDefinition chargeDefinition);
@RequestMapping(
value = "/products/{productidentifier}/charges/{chargedefinitionidentifier}",
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 a7f1ffc..8cd4fc0 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
@@ -15,6 +15,7 @@
*/
package io.mifos.portfolio.api.v1.domain;
+import io.mifos.portfolio.api.v1.validation.ValidChargeReference;
import io.mifos.portfolio.api.v1.validation.ValidPaymentCycleUnit;
import io.mifos.core.lang.validation.constraints.ValidIdentifier;
import org.hibernate.validator.constraints.NotBlank;
@@ -61,7 +62,7 @@
@NotNull
private ChargeMethod chargeMethod;
- @ValidIdentifier(optional = true)
+ @ValidChargeReference
private String proportionalTo;
@ValidIdentifier
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckAccountAssignments.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckAccountAssignments.java
index b579e03..e92cece 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckAccountAssignments.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckAccountAssignments.java
@@ -26,7 +26,6 @@
/**
* @author Myrle Krantz
*/
-@SuppressWarnings("WeakerAccess")
public class CheckAccountAssignments implements ConstraintValidator<ValidAccountAssignments, Set<AccountAssignment>> {
public CheckAccountAssignments() {
}
@@ -42,9 +41,8 @@
.map(AccountAssignment::getDesignator)
.collect(Collectors.toList());
- boolean allValidAccountAssignments = !(accountAssignments.stream()
- .filter(x -> !isValidAccountAssignment(x))
- .findAny().isPresent());
+ final boolean allValidAccountAssignments = accountAssignments.stream()
+ .allMatch(this::isValidAccountAssignment);
if (!allValidAccountAssignments)
return false;
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckChargeReference.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckChargeReference.java
new file mode 100644
index 0000000..ab16e8b
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckChargeReference.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.portfolio.api.v1.validation;
+
+import io.mifos.core.lang.validation.CheckIdentifier;
+import io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+/**
+ * @author Myrle Krantz
+ */
+public class CheckChargeReference implements ConstraintValidator<ValidChargeReference, String> {
+
+ @Override
+ public void initialize(ValidChargeReference constraintAnnotation) {
+
+ }
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ if (value == null)
+ return true;
+
+ if (value.equals(ChargeIdentifiers.MAXIMUM_BALANCE_DESIGNATOR) ||
+ value.equals(ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR))
+ return true;
+
+ final CheckIdentifier identifierChecker = new CheckIdentifier();
+ return identifierChecker.isValid(value, context);
+ }
+}
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidChargeReference.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidChargeReference.java
new file mode 100644
index 0000000..130ac59
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidChargeReference.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.portfolio.api.v1.validation;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.*;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Constraint(
+ validatedBy = {CheckChargeReference.class}
+)
+public @interface ValidChargeReference {
+ String message() default "Only valid identifiers or {delegates} can be charge references.";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends Payload>[] payload() default {};
+}
diff --git a/api/src/test/java/io/mifos/portfolio/api/v1/domain/ChargeDefinitionTest.java b/api/src/test/java/io/mifos/portfolio/api/v1/domain/ChargeDefinitionTest.java
index 0838319..750be0b 100644
--- a/api/src/test/java/io/mifos/portfolio/api/v1/domain/ChargeDefinitionTest.java
+++ b/api/src/test/java/io/mifos/portfolio/api/v1/domain/ChargeDefinitionTest.java
@@ -103,6 +103,12 @@
x.setProportionalTo(null);
})
.valid(true));
+ ret.add(new ValidationTestCase<ChargeDefinition>("proportionalToRunningBalance")
+ .adjustment(x -> x.setProportionalTo("{runningbalance}"))
+ .valid(true));
+ ret.add(new ValidationTestCase<ChargeDefinition>("proportionalToMaximumBalance")
+ .adjustment(x -> x.setProportionalTo("{maximumbalance}"))
+ .valid(true));
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 4e7a98e..4e27123 100644
--- a/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java
+++ b/component-test/src/main/java/io/mifos/portfolio/AbstractPortfolioTest.java
@@ -18,10 +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.test.env.TestEnvironment;
import io.mifos.core.test.fixture.TenantDataStoreContextTestRule;
-import io.mifos.core.test.fixture.cassandra.CassandraInitializer;
-import io.mifos.core.test.fixture.mariadb.MariaDBInitializer;
import io.mifos.core.test.listener.EnableEventRecording;
import io.mifos.core.test.listener.EventRecorder;
import io.mifos.individuallending.api.v1.client.IndividualLending;
@@ -37,8 +34,6 @@
import io.mifos.portfolio.service.internal.util.AccountingAdapter;
import io.mifos.portfolio.service.internal.util.RhythmAdapter;
import org.junit.*;
-import org.junit.rules.RuleChain;
-import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.slf4j.Logger;
@@ -76,8 +71,7 @@
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
classes = {AbstractPortfolioTest.TestConfiguration.class})
-public class AbstractPortfolioTest {
- private static final String APP_NAME = "portfolio-v1";
+public class AbstractPortfolioTest extends SuiteTestEnvironment {
private static final String LOGGER_NAME = "test-logger";
@Configuration
@@ -107,17 +101,9 @@
static final String TEST_USER = "setau";
- final static TestEnvironment testEnvironment = new TestEnvironment(APP_NAME);
- private final static CassandraInitializer cassandraInitializer = new CassandraInitializer();
- private final static MariaDBInitializer mariaDBInitializer = new MariaDBInitializer();
- private final static TenantDataStoreContextTestRule tenantDataStoreContext = TenantDataStoreContextTestRule.forRandomTenantName(cassandraInitializer, mariaDBInitializer);
-
@ClassRule
- public static TestRule orderClassRules = RuleChain
- .outerRule(testEnvironment)
- .around(cassandraInitializer)
- .around(mariaDBInitializer)
- .around(tenantDataStoreContext);
+ public final static TenantDataStoreContextTestRule tenantDataStoreContext
+ = TenantDataStoreContextTestRule.forRandomTenantName(cassandraInitializer, mariaDBInitializer);
@Rule
public final TenantApplicationSecurityEnvironmentTestRule tenantApplicationSecurityEnvironment
@@ -213,8 +199,7 @@
command.setOneTimeAccountAssignments(oneTimeAccountAssignments);
portfolioManager.executeCaseCommand(productIdentifier, caseIdentifier, action.name(), command);
- Assert.assertTrue(eventRecorder.waitForMatch(event,
- (IndividualLoanCommandEvent x) -> individualLoanCommandEventMatches(x, productIdentifier, caseIdentifier)));
+ Assert.assertTrue(eventRecorder.wait(event, new IndividualLoanCommandEvent(productIdentifier, caseIdentifier)));
final Case customerCase = portfolioManager.getCase(productIdentifier, caseIdentifier);
Assert.assertEquals(customerCase.getCurrentState(), nextState.name());
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 89f9ccb..d838179 100644
--- a/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java
+++ b/component-test/src/main/java/io/mifos/portfolio/AccountingFixture.java
@@ -20,14 +20,15 @@
import org.hamcrest.Description;
import org.mockito.AdditionalMatchers;
import org.mockito.ArgumentMatcher;
+import org.mockito.Matchers;
import org.mockito.Mockito;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
import javax.validation.Validation;
import javax.validation.Validator;
import java.math.BigDecimal;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Set;
+import java.util.*;
import static org.mockito.Matchers.argThat;
@@ -53,6 +54,8 @@
static final String LOAN_INTEREST_ACCRUAL_ACCOUNT = "7810";
static final String CONSUMER_LOAN_INTEREST_ACCOUNT = "1103";
+ static final Map<String, Account> accountMap = new HashMap<>();
+
private static Ledger cashLedger() {
final Ledger ret = new Ledger();
@@ -111,60 +114,60 @@
}
- private static Account loanFundsSourceAccount() {
+ private static void loanFundsSourceAccount() {
final Account ret = new Account();
ret.setIdentifier(LOAN_FUNDS_SOURCE_ACCOUNT_IDENTIFIER);
ret.setLedger(CASH_LEDGER_IDENTIFIER);
ret.setType(AccountType.ASSET.name());
- return ret;
+ accountMap.put(LOAN_FUNDS_SOURCE_ACCOUNT_IDENTIFIER, ret);
}
- private static Account processingFeeIncomeAccount() {
+ private static void processingFeeIncomeAccount() {
final Account ret = new Account();
ret.setIdentifier(PROCESSING_FEE_INCOME_ACCOUNT_IDENTIFIER);
ret.setLedger(FEES_AND_CHARGES_LEDGER_IDENTIFIER);
ret.setType(AccountType.REVENUE.name());
- return ret;
+ accountMap.put(PROCESSING_FEE_INCOME_ACCOUNT_IDENTIFIER, ret);
}
- private static Account loanOriginationFeesIncomeAccount() {
+ private static void loanOriginationFeesIncomeAccount() {
final Account ret = new Account();
ret.setIdentifier(LOAN_ORIGINATION_FEES_ACCOUNT_IDENTIFIER);
ret.setLedger(FEES_AND_CHARGES_LEDGER_IDENTIFIER);
ret.setType(AccountType.REVENUE.name());
- return ret;
+ accountMap.put(LOAN_ORIGINATION_FEES_ACCOUNT_IDENTIFIER, ret);
}
- private static Account disbursementFeeIncomeAccount() {
+ private static void disbursementFeeIncomeAccount() {
final Account ret = new Account();
ret.setIdentifier(DISBURSEMENT_FEE_INCOME_ACCOUNT_IDENTIFIER);
ret.setLedger(FEES_AND_CHARGES_LEDGER_IDENTIFIER);
ret.setType(AccountType.REVENUE.name());
- return ret;
+ accountMap.put(DISBURSEMENT_FEE_INCOME_ACCOUNT_IDENTIFIER, ret);
}
- private static Account tellerOneAccount() {
+ private static void tellerOneAccount() {
final Account ret = new Account();
ret.setIdentifier(TELLER_ONE_ACCOUNT_IDENTIFIER);
ret.setLedger(CASH_LEDGER_IDENTIFIER);
ret.setType(AccountType.ASSET.name());
- return ret;
+ accountMap.put(TELLER_ONE_ACCOUNT_IDENTIFIER, ret);
}
- private static Account loanInterestAccrualAccount() {
+ private static void loanInterestAccrualAccount() {
final Account ret = new Account();
ret.setIdentifier(LOAN_INTEREST_ACCRUAL_ACCOUNT);
ret.setLedger(ACCRUED_INCOME_LEDGER_IDENTIFIER);
ret.setType(AccountType.ASSET.name());
- return ret;
+ accountMap.put(LOAN_INTEREST_ACCRUAL_ACCOUNT, ret);
}
- private static Account consumerLoanInterestAccount() {
+ private static void consumerLoanInterestAccount() {
final Account ret = new Account();
ret.setIdentifier(CONSUMER_LOAN_INTEREST_ACCOUNT);
ret.setLedger(LOAN_INCOME_LEDGER_IDENTIFIER);
ret.setType(AccountType.REVENUE.name());
- return ret;
+ accountMap.put(CONSUMER_LOAN_INTEREST_ACCOUNT, ret);
}
private static AccountPage customerLoanAccountsPage() {
@@ -290,7 +293,32 @@
}
}
+ private static class FindAccountAnswer implements Answer {
+ @Override
+ public Account answer(InvocationOnMock invocation) throws Throwable {
+ final String identifier = invocation.getArgumentAt(0, String.class);
+ return accountMap.get(identifier);
+ }
+ }
+
+ private static class CreateAccountAnswer implements Answer {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ final Account account = invocation.getArgumentAt(0, Account.class);
+ accountMap.put(account.getIdentifier(), account);
+ return null;
+ }
+ }
+
static void mockAccountingPrereqs(final LedgerManager ledgerManagerMock) {
+ loanFundsSourceAccount();
+ loanOriginationFeesIncomeAccount();
+ processingFeeIncomeAccount();
+ disbursementFeeIncomeAccount();
+ tellerOneAccount();
+ loanInterestAccrualAccount();
+ consumerLoanInterestAccount();
+
Mockito.doReturn(incomeLedger()).when(ledgerManagerMock).findLedger(INCOME_LEDGER_IDENTIFIER);
Mockito.doReturn(feesAndChargesLedger()).when(ledgerManagerMock).findLedger(FEES_AND_CHARGES_LEDGER_IDENTIFIER);
Mockito.doReturn(cashLedger()).when(ledgerManagerMock).findLedger(CASH_LEDGER_IDENTIFIER);
@@ -298,17 +326,17 @@
Mockito.doReturn(customerLoanLedger()).when(ledgerManagerMock).findLedger(CUSTOMER_LOAN_LEDGER_IDENTIFIER);
Mockito.doReturn(loanIncomeLedger()).when(ledgerManagerMock).findLedger(LOAN_INCOME_LEDGER_IDENTIFIER);
Mockito.doReturn(accruedIncomeLedger()).when(ledgerManagerMock).findLedger(ACCRUED_INCOME_LEDGER_IDENTIFIER);
- Mockito.doReturn(loanFundsSourceAccount()).when(ledgerManagerMock).findAccount(LOAN_FUNDS_SOURCE_ACCOUNT_IDENTIFIER);
- Mockito.doReturn(loanOriginationFeesIncomeAccount()).when(ledgerManagerMock).findAccount(LOAN_ORIGINATION_FEES_ACCOUNT_IDENTIFIER);
- Mockito.doReturn(processingFeeIncomeAccount()).when(ledgerManagerMock).findAccount(PROCESSING_FEE_INCOME_ACCOUNT_IDENTIFIER);
- Mockito.doReturn(disbursementFeeIncomeAccount()).when(ledgerManagerMock).findAccount(DISBURSEMENT_FEE_INCOME_ACCOUNT_IDENTIFIER);
- Mockito.doReturn(tellerOneAccount()).when(ledgerManagerMock).findAccount(TELLER_ONE_ACCOUNT_IDENTIFIER);
- Mockito.doReturn(loanInterestAccrualAccount()).when(ledgerManagerMock).findAccount(LOAN_INTEREST_ACCRUAL_ACCOUNT);
- Mockito.doReturn(consumerLoanInterestAccount()).when(ledgerManagerMock).findAccount(CONSUMER_LOAN_INTEREST_ACCOUNT);
Mockito.doReturn(customerLoanAccountsPage()).when(ledgerManagerMock).fetchAccountsOfLedger(Mockito.eq(CUSTOMER_LOAN_LEDGER_IDENTIFIER),
Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
Mockito.doReturn(pendingDisbursalAccountsPage()).when(ledgerManagerMock).fetchAccountsOfLedger(Mockito.eq(PENDING_DISBURSAL_LEDGER_IDENTIFIER),
Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
+
+ Mockito.doAnswer(new FindAccountAnswer()).when(ledgerManagerMock).findAccount(Matchers.anyString());
+ Mockito.doAnswer(new CreateAccountAnswer()).when(ledgerManagerMock).createAccount(Matchers.any());
+ }
+
+ static void mockBalance(final String accountIdentifier, final BigDecimal balance) {
+ accountMap.get(accountIdentifier).setBalance(balance.doubleValue());
}
static String verifyAccountCreation(final LedgerManager ledgerManager,
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 5d60486..088bdbe 100644
--- a/component-test/src/main/java/io/mifos/portfolio/Fixture.java
+++ b/component-test/src/main/java/io/mifos/portfolio/Fixture.java
@@ -23,6 +23,7 @@
import io.mifos.individuallending.api.v1.domain.product.ProductParameters;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Consumer;
@@ -37,8 +38,10 @@
@SuppressWarnings({"WeakerAccess", "unused"})
public class Fixture {
+ static final int MINOR_CURRENCY_UNIT_DIGITS = 2;
+ static final BigDecimal INTEREST_RATE = BigDecimal.valueOf(0.10).setScale(4, RoundingMode.HALF_EVEN);
+ static final BigDecimal ACCRUAL_PERIODS = BigDecimal.valueOf(365.2425);
- public static final int MINOR_CURRENCY_UNIT_DIGITS = 2;
private static int uniquenessSuffix = 0;
static public Product getTestProduct() {
diff --git a/component-test/src/main/java/io/mifos/portfolio/SuiteTestEnvironment.java b/component-test/src/main/java/io/mifos/portfolio/SuiteTestEnvironment.java
new file mode 100644
index 0000000..3b84ee0
--- /dev/null
+++ b/component-test/src/main/java/io/mifos/portfolio/SuiteTestEnvironment.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.portfolio;
+
+import io.mifos.core.test.env.TestEnvironment;
+import io.mifos.core.test.fixture.cassandra.CassandraInitializer;
+import io.mifos.core.test.fixture.mariadb.MariaDBInitializer;
+import org.junit.ClassRule;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TestRule;
+import org.junit.rules.RunExternalResourceOnce;
+
+/**
+ * @author Myrle Krantz
+ */
+public class SuiteTestEnvironment {
+ static final String APP_NAME = "portfolio-v1";
+ static final TestEnvironment testEnvironment = new TestEnvironment(APP_NAME);
+ static final CassandraInitializer cassandraInitializer = new CassandraInitializer();
+ static final MariaDBInitializer mariaDBInitializer = new MariaDBInitializer();
+
+ @ClassRule
+ public static TestRule orderClassRules = RuleChain
+ .outerRule(new RunExternalResourceOnce(testEnvironment))
+ .around(new RunExternalResourceOnce(cassandraInitializer))
+ .around(new RunExternalResourceOnce(mariaDBInitializer));
+}
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 7ec81c9..ecf8a64 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestAccountingInteractionInLoanWorkflow.java
@@ -19,35 +19,45 @@
import io.mifos.accounting.api.v1.domain.AccountType;
import io.mifos.accounting.api.v1.domain.Creditor;
import io.mifos.accounting.api.v1.domain.Debtor;
+import io.mifos.core.api.util.ApiFactory;
+import io.mifos.core.lang.DateConverter;
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.workflow.Action;
+import io.mifos.individuallending.api.v1.events.IndividualLoanCommandEvent;
+import io.mifos.individuallending.api.v1.events.IndividualLoanEventConstants;
import io.mifos.portfolio.api.v1.domain.Case;
+import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.api.v1.domain.Product;
+import io.mifos.portfolio.api.v1.events.ChargeDefinitionEvent;
import io.mifos.portfolio.api.v1.events.EventConstants;
+import io.mifos.rhythm.spi.v1.client.BeatListener;
+import io.mifos.rhythm.spi.v1.domain.BeatPublish;
+import io.mifos.rhythm.spi.v1.events.BeatPublishEvent;
import org.junit.Assert;
-import org.junit.FixMethodOrder;
+import org.junit.Before;
import org.junit.Test;
-import org.junit.runners.MethodSorters;
import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
-import static io.mifos.individuallending.api.v1.events.IndividualLoanEventConstants.*;
import static io.mifos.portfolio.Fixture.MINOR_CURRENCY_UNIT_DIGITS;
/**
* @author Myrle Krantz
*/
-@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestAccountingInteractionInLoanWorkflow extends AbstractPortfolioTest {
private static final BigDecimal PROCESSING_FEE_AMOUNT = BigDecimal.valueOf(10_0000, MINOR_CURRENCY_UNIT_DIGITS);
private static final BigDecimal LOAN_ORIGINATION_FEE_AMOUNT = BigDecimal.valueOf(100_0000, MINOR_CURRENCY_UNIT_DIGITS);
private static final BigDecimal DISBURSEMENT_FEE_AMOUNT = BigDecimal.valueOf(1_0000, MINOR_CURRENCY_UNIT_DIGITS);
+ private BeatListener portfolioBeatListener;
+
private static Product product;
private static Case customerCase;
private static CaseParameters caseParameters;
@@ -55,22 +65,52 @@
private static String customerLoanAccountIdentifier;
+ @Before
+ public void prepBeatListener() {
+ portfolioBeatListener = new ApiFactory(logger).create(BeatListener.class, testEnvironment.serverURI());
+ }
+
@Test
- public void step1CreateProduct() throws InterruptedException {
- //Create product and set charges to fixed fees.
+ public void workflowTerminatingInApplicationDenial() throws InterruptedException {
+ step1CreateProduct();
+ step2CreateCase();
+ step3OpenCase();
+ step4DenyCase();
+ }
+
+ @Test
+ public void workflowTerminatingInEarlyLoanPayoff() throws InterruptedException {
+ step1CreateProduct();
+ step2CreateCase();
+ step3OpenCase();
+ step4ApproveCase();
+ step5DisburseFullAmount();
+ step6CalculateInterestAccrual();
+ //step7PaybackFullAmount();
+ }
+
+ //Create product and set charges to fixed fees.
+ private void step1CreateProduct() throws InterruptedException {
+ logger.info("step1CreateProduct");
product = createProduct();
setFeeToFixedValue(product.getIdentifier(), ChargeIdentifiers.PROCESSING_FEE_ID, PROCESSING_FEE_AMOUNT);
setFeeToFixedValue(product.getIdentifier(), ChargeIdentifiers.LOAN_ORIGINATION_FEE_ID, LOAN_ORIGINATION_FEE_AMOUNT);
setFeeToFixedValue(product.getIdentifier(), ChargeIdentifiers.DISBURSEMENT_FEE_ID, DISBURSEMENT_FEE_AMOUNT);
+ final ChargeDefinition interestChargeDefinition = portfolioManager.getChargeDefinition(product.getIdentifier(), ChargeIdentifiers.INTEREST_ID);
+ interestChargeDefinition.setAmount(Fixture.INTEREST_RATE);
+
+ portfolioManager.changeChargeDefinition(product.getIdentifier(), interestChargeDefinition.getIdentifier(), interestChargeDefinition);
+ Assert.assertTrue(this.eventRecorder.wait(EventConstants.PUT_CHARGE_DEFINITION,
+ new ChargeDefinitionEvent(product.getIdentifier(), interestChargeDefinition.getIdentifier())));
+
portfolioManager.enableProduct(product.getIdentifier(), true);
Assert.assertTrue(this.eventRecorder.wait(EventConstants.PUT_PRODUCT_ENABLE, product.getIdentifier()));
}
- @Test
- public void step2CreateCase() throws InterruptedException {
- //Create case.
+ private void step2CreateCase() throws InterruptedException {
+ logger.info("step2CreateCase");
caseParameters = Fixture.createAdjustedCaseParameters(x -> {
});
final String caseParametersAsString = new Gson().toJson(caseParameters);
@@ -82,14 +122,14 @@
}
//Open the case and accept a processing fee.
- @Test
- public void step3OpenCase() throws InterruptedException {
+ private void step3OpenCase() throws InterruptedException {
+ logger.info("step3OpenCase");
checkStateTransfer(
product.getIdentifier(),
customerCase.getIdentifier(),
Action.OPEN,
Collections.singletonList(assignEntryToTeller()),
- OPEN_INDIVIDUALLOAN_CASE,
+ IndividualLoanEventConstants.OPEN_INDIVIDUALLOAN_CASE,
Case.State.PENDING);
checkNextActionsCorrect(product.getIdentifier(), customerCase.getIdentifier(), Action.APPROVE, Action.DENY);
checkCostComponentForActionCorrect(product.getIdentifier(), customerCase.getIdentifier(), Action.APPROVE,
@@ -103,15 +143,29 @@
}
+ //Deny the case. Once this is done, no more actions are possible for the case.
+ private void step4DenyCase() throws InterruptedException {
+ logger.info("step4DenyCase");
+ checkStateTransfer(
+ product.getIdentifier(),
+ customerCase.getIdentifier(),
+ Action.DENY,
+ Collections.singletonList(assignEntryToTeller()),
+ IndividualLoanEventConstants.DENY_INDIVIDUALLOAN_CASE,
+ Case.State.CLOSED);
+ checkNextActionsCorrect(product.getIdentifier(), customerCase.getIdentifier());
+ }
+
+
//Approve the case, accept a loan origination fee, and prepare to disburse the loan by earmarking the funds.
- @Test
- public void step4ApproveCase() throws InterruptedException {
+ private void step4ApproveCase() throws InterruptedException {
+ logger.info("step4ApproveCase");
checkStateTransfer(
product.getIdentifier(),
customerCase.getIdentifier(),
Action.APPROVE,
Collections.singletonList(assignEntryToTeller()),
- APPROVE_INDIVIDUALLOAN_CASE,
+ IndividualLoanEventConstants.APPROVE_INDIVIDUALLOAN_CASE,
Case.State.APPROVED);
checkNextActionsCorrect(product.getIdentifier(), customerCase.getIdentifier(), Action.DISBURSE, Action.CLOSE);
@@ -131,14 +185,14 @@
}
//Approve the case, accept a loan origination fee, and prepare to disburse the loan by earmarking the funds.
- @Test
- public void step5DisburseFullAmount() throws InterruptedException {
+ private void step5DisburseFullAmount() throws InterruptedException {
+ logger.info("step5DisburseFullAmount");
checkStateTransfer(
product.getIdentifier(),
customerCase.getIdentifier(),
Action.DISBURSE,
Collections.singletonList(assignEntryToTeller()),
- DISBURSE_INDIVIDUALLOAN_CASE,
+ IndividualLoanEventConstants.DISBURSE_INDIVIDUALLOAN_CASE,
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);
@@ -154,4 +208,39 @@
AccountingFixture.verifyTransfer(ledgerManager, debtors, creditors);
}
+
+ //Perform daily interest calculation.
+ private void step6CalculateInterestAccrual() throws InterruptedException {
+ logger.info("step6CalculateInterestAccrual");
+ final String beatIdentifier = "alignment0";
+ final String midnightTimeStamp = DateConverter.toIsoString(LocalDateTime.now().truncatedTo(ChronoUnit.DAYS));
+
+ AccountingFixture.mockBalance(customerLoanAccountIdentifier, caseParameters.getMaximumBalance());
+
+ 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(eventRecorder.wait(IndividualLoanEventConstants.APPLY_INTEREST_INDIVIDUALLOAN_CASE,
+ new IndividualLoanCommandEvent(product.getIdentifier(), customerCase.getIdentifier())));
+
+ final Case customerCaseAfterStateChange = portfolioManager.getCase(product.getIdentifier(), customerCase.getIdentifier());
+ Assert.assertEquals(customerCaseAfterStateChange.getCurrentState(), Case.State.ACTIVE.name());
+
+ final String calculatedInterest = caseParameters.getMaximumBalance().multiply(Fixture.INTEREST_RATE.divide(Fixture.ACCRUAL_PERIODS, 8, BigDecimal.ROUND_HALF_EVEN))
+ .setScale(MINOR_CURRENCY_UNIT_DIGITS, BigDecimal.ROUND_HALF_EVEN)
+ .toPlainString();
+
+ final Set<Debtor> debtors = new HashSet<>();
+ debtors.add(new Debtor(
+ AccountingFixture.LOAN_INTEREST_ACCRUAL_ACCOUNT,
+ calculatedInterest));
+
+ final Set<Creditor> creditors = new HashSet<>();
+ creditors.add(new Creditor(
+ customerLoanAccountIdentifier,
+ calculatedInterest));
+ AccountingFixture.verifyTransfer(ledgerManager, debtors, creditors);
+ }
}
\ No newline at end of file
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestChargeDefinitions.java b/component-test/src/main/java/io/mifos/portfolio/TestChargeDefinitions.java
index b67db68..293abf2 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestChargeDefinitions.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestChargeDefinitions.java
@@ -66,6 +66,7 @@
try {
portfolioManager.getChargeDefinition(product.getIdentifier(), chargeDefinitionToDelete.getIdentifier());
+ //noinspection ConstantConditions
Assert.assertFalse(true);
}
catch (final NotFoundException ignored) { }
@@ -92,4 +93,26 @@
final ChargeDefinition chargeDefinitionAsCreated = portfolioManager.getChargeDefinition(product.getIdentifier(), chargeDefinition.getIdentifier());
Assert.assertEquals(chargeDefinition, chargeDefinitionAsCreated);
}
+
+
+ @Test
+ public void shouldChangeInterestChargeDefinition() throws InterruptedException {
+ final Product product = createProduct();
+
+ final ChargeDefinition interestChargeDefinition
+ = portfolioManager.getChargeDefinition(product.getIdentifier(), ChargeIdentifiers.INTEREST_ID);
+ interestChargeDefinition.setAmount(Fixture.INTEREST_RATE);
+
+ portfolioManager.changeChargeDefinition(
+ product.getIdentifier(),
+ interestChargeDefinition.getIdentifier(),
+ interestChargeDefinition);
+ Assert.assertTrue(this.eventRecorder.wait(EventConstants.PUT_CHARGE_DEFINITION,
+ new ChargeDefinitionEvent(product.getIdentifier(), interestChargeDefinition.getIdentifier())));
+
+ final ChargeDefinition chargeDefinitionAsChanged
+ = portfolioManager.getChargeDefinition(product.getIdentifier(), interestChargeDefinition.getIdentifier());
+
+ Assert.assertEquals(interestChargeDefinition, chargeDefinitionAsChanged);
+ }
}
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestCommands.java b/component-test/src/main/java/io/mifos/portfolio/TestCommands.java
index dae6f62..96944a7 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestCommands.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestCommands.java
@@ -151,6 +151,20 @@
}
@Test
+ public void testApproveBeforeOpen() throws InterruptedException {
+ final Product product = createAndEnableProduct();
+ final Case customerCase = createCase(product.getIdentifier());
+
+ checkStateTransferFails(
+ product.getIdentifier(),
+ customerCase.getIdentifier(),
+ Action.APPROVE,
+ Collections.singletonList(assignEntryToTeller()),
+ APPROVE_INDIVIDUALLOAN_CASE,
+ Case.State.CREATED);
+ }
+
+ @Test
public void testDisburseBeforeApproval() throws InterruptedException {
final Product product = createAndEnableProduct();
final Case customerCase = createCase(product.getIdentifier());
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestProducts.java b/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
index 4b73bf3..876d07a 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
@@ -331,7 +331,7 @@
product.setName(StringUtils.repeat("x", 256));
product.setDescription(StringUtils.repeat("x", 4096));
product.setTermRange(new TermRange(ChronoUnit.MONTHS, 12));
- product.setBalanceRange(new BalanceRange(Fixture.fixScale(BigDecimal.ZERO), Fixture.fixScale(new BigDecimal(10000))));
+ product.setBalanceRange(new BalanceRange(BigDecimal.ZERO.setScale(4, BigDecimal.ROUND_UNNECESSARY), new BigDecimal(10000).setScale(4, BigDecimal.ROUND_UNNECESSARY)));
product.setInterestRange(new InterestRange(new BigDecimal("999.98"), new BigDecimal("999.99")));
product.setInterestBasis(InterestBasis.CURRENT_BALANCE);
product.setCurrencyCode("XTS");
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestSuite.java b/component-test/src/main/java/io/mifos/portfolio/TestSuite.java
new file mode 100644
index 0000000..4fba15b
--- /dev/null
+++ b/component-test/src/main/java/io/mifos/portfolio/TestSuite.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.portfolio;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * @author Myrle Krantz
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ TestAccountingInteractionInLoanWorkflow.class,
+ TestCases.class,
+ TestChargeDefinitions.class,
+ TestCommands.class,
+ TestIndividualLoans.class,
+ TestPatterns.class,
+ TestProducts.class,
+ TestTaskDefinitions.class
+})
+public class TestSuite extends SuiteTestEnvironment {
+}
diff --git a/component-test/src/main/java/io/mifos/portfolio/listener/BeatPublishEventListener.java b/component-test/src/main/java/io/mifos/portfolio/listener/BeatPublishEventListener.java
new file mode 100644
index 0000000..7251ff7
--- /dev/null
+++ b/component-test/src/main/java/io/mifos/portfolio/listener/BeatPublishEventListener.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.portfolio.listener;
+
+import io.mifos.core.lang.config.TenantHeaderFilter;
+import io.mifos.core.test.listener.EventRecorder;
+import io.mifos.rhythm.spi.v1.events.EventConstants;
+import io.mifos.rhythm.spi.v1.events.BeatPublishEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jms.annotation.JmsListener;
+import org.springframework.messaging.handler.annotation.Header;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+@Component
+public class BeatPublishEventListener {
+ private final EventRecorder eventRecorder;
+
+ @Autowired
+ public BeatPublishEventListener(final EventRecorder eventRecorder) {
+ super();
+ this.eventRecorder = eventRecorder;
+ }
+
+ @JmsListener(
+ subscription = io.mifos.portfolio.api.v1.events.EventConstants.DESTINATION,
+ destination = io.mifos.portfolio.api.v1.events.EventConstants.DESTINATION,
+ selector = EventConstants.SELECTOR_POST_PUBLISHEDBEAT
+ )
+ public void onPublishBeat(@Header(TenantHeaderFilter.TENANT_HEADER) final String tenant,
+ final String payload) {
+ this.eventRecorder.event(tenant, EventConstants.POST_PUBLISHEDBEAT, payload, BeatPublishEvent.class);
+ }
+}
diff --git a/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java b/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
index 14aa235..6be1dae 100644
--- a/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
+++ b/service/src/main/java/io/mifos/individuallending/IndividualLendingPatternFactory.java
@@ -16,6 +16,7 @@
package io.mifos.individuallending;
import com.google.gson.Gson;
+import io.mifos.core.lang.ServiceException;
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.workflow.Action;
@@ -25,6 +26,7 @@
import io.mifos.individuallending.internal.repository.CaseParametersRepository;
import io.mifos.individuallending.internal.repository.CreditWorthinessFactorType;
import io.mifos.individuallending.internal.service.CostComponentService;
+import io.mifos.individuallending.internal.service.DataContextOfAction;
import io.mifos.portfolio.api.v1.domain.Case;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
@@ -145,14 +147,14 @@
writeOffAllowanceCharge.setProportionalTo(ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR);
final ChargeDefinition interestCharge = charge(
- INTEREST_NAME,
- Action.ACCEPT_PAYMENT,
- BigDecimal.valueOf(0.05),
- CUSTOMER_LOAN,
- PENDING_DISBURSAL);
+ INTEREST_NAME,
+ Action.ACCEPT_PAYMENT,
+ BigDecimal.valueOf(0.05),
+ INTEREST_ACCRUAL,
+ PENDING_DISBURSAL);
interestCharge.setForCycleSizeUnit(ChronoUnit.YEARS);
interestCharge.setAccrueAction(Action.APPLY_INTEREST.name());
- interestCharge.setAccrualAccountDesignator(INTEREST_ACCRUAL);
+ interestCharge.setAccrualAccountDesignator(CUSTOMER_LOAN);
interestCharge.setProportionalTo(ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR);
final ChargeDefinition disbursementReturnCharge = charge(
@@ -263,13 +265,26 @@
final String productIdentifier,
final String caseIdentifier,
final String actionIdentifier) {
- return costComponentService.getCostComponents(productIdentifier, caseIdentifier, Action.valueOf(actionIdentifier))
+ final Action action = Action.valueOf(actionIdentifier);
+ final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, Collections.emptyList());
+ final Case.State caseState = Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState());
+ checkActionCanBeExecuted(caseState, action);
+
+ return costComponentService.getCostComponentsForAction(action, dataContextOfAction)
+ .stream()
+ .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());
}
- public static Set<Action> getAllowedNextActionsForState(Case.State state) {
+ public static void checkActionCanBeExecuted(final Case.State state, final Action action) {
+ if (!getAllowedNextActionsForState(state).contains(action))
+ throw ServiceException.badRequest("Cannot call action {0} from state {1}", action.name(), state.name());
+ }
+
+ private static Set<Action> getAllowedNextActionsForState(final Case.State state) {
switch (state)
{
case CREATED:
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
new file mode 100644
index 0000000..1e658f3
--- /dev/null
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/ApplyInterestCommand.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.individuallending.internal.command;
+
+/**
+ * @author Myrle Krantz
+ */
+public class ApplyInterestCommand {
+ private final String productIdentifier;
+ private final String caseIdentifier;
+
+ public ApplyInterestCommand(String productIdentifier, String caseIdentifier) {
+ this.productIdentifier = productIdentifier;
+ this.caseIdentifier = caseIdentifier;
+ }
+
+ public String getProductIdentifier() {
+ return productIdentifier;
+ }
+
+ public String getCaseIdentifier() {
+ return caseIdentifier;
+ }
+
+ @Override
+ public String toString() {
+ return "ApplyInterestCommand{" +
+ "productIdentifier='" + productIdentifier + '\'' +
+ ", caseIdentifier='" + caseIdentifier + '\'' +
+ '}';
+ }
+}
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
new file mode 100644
index 0000000..af4abc5
--- /dev/null
+++ b/service/src/main/java/io/mifos/individuallending/internal/command/handler/BeatPublishCommandHandler.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.mifos.individuallending.internal.command.handler;
+
+import io.mifos.core.command.annotation.Aggregate;
+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.command.internal.CommandBus;
+import io.mifos.core.lang.ApplicationName;
+import io.mifos.core.lang.DateConverter;
+import io.mifos.individuallending.internal.command.ApplyInterestCommand;
+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.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.time.LocalDateTime;
+import java.util.Collections;
+import java.util.stream.Stream;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+@Aggregate
+public class BeatPublishCommandHandler {
+ private final CaseRepository caseRepository;
+ private final PortfolioProperties portfolioProperties;
+ private final ApplicationName applicationName;
+ private final CommandBus commandBus;
+
+ @Autowired
+ public BeatPublishCommandHandler(
+ final CaseRepository caseRepository,
+ final PortfolioProperties portfolioProperties,
+ final ApplicationName applicationName,
+ final CommandBus commandBus) {
+ this.caseRepository = caseRepository;
+ this.portfolioProperties = portfolioProperties;
+ this.applicationName = applicationName;
+ this.commandBus = commandBus;
+ }
+
+ @Transactional
+ @CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
+ @EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = EventConstants.POST_PUBLISHEDBEAT)
+ public BeatPublishEvent process(final CreateBeatPublishCommand createBeatPublishCommand) {
+ final BeatPublish instance = createBeatPublishCommand.getInstance();
+ final LocalDateTime forTime = DateConverter.fromIsoString(instance.getForTime());
+ if (portfolioProperties.getBookInterestInTimeSlot() == forTime.getHour())
+ {
+ final Stream<CaseEntity> activeCases = caseRepository.findByCurrentStateIn(Collections.singleton(Case.State.ACTIVE.name()));
+ activeCases.forEach(activeCase -> {
+ final ApplyInterestCommand applyInterestCommand = new ApplyInterestCommand(activeCase.getProductIdentifier(), activeCase.getIdentifier());
+ commandBus.dispatch(applyInterestCommand);
+ });
+ }
+
+ return new BeatPublishEvent(applicationName.toString(), instance.getIdentifier(), instance.getForTime());
+ }
+}
\ 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 467c25a..f72ca48 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
@@ -30,9 +30,12 @@
import io.mifos.individuallending.internal.service.*;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
import io.mifos.portfolio.api.v1.domain.Case;
+import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
+import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.api.v1.events.EventConstants;
import io.mifos.portfolio.service.internal.mapper.CaseMapper;
-import io.mifos.portfolio.service.internal.repository.*;
+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.portfolio.service.internal.util.ChargeInstance;
import org.springframework.beans.factory.annotation.Autowired;
@@ -40,8 +43,11 @@
import java.math.BigDecimal;
import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
import java.time.ZoneId;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -53,75 +59,91 @@
public class IndividualLoanCommandHandler {
private final CaseRepository caseRepository;
private final CostComponentService costComponentService;
- private final IndividualLoanService individualLoanService;
private final AccountingAdapter accountingAdapter;
@Autowired
public IndividualLoanCommandHandler(
- final CaseRepository caseRepository,
- final CostComponentService costComponentService,
- final IndividualLoanService individualLoanService,
- final AccountingAdapter accountingAdapter) {
+ final CaseRepository caseRepository,
+ final CostComponentService costComponentService,
+ final AccountingAdapter accountingAdapter) {
this.caseRepository = caseRepository;
this.costComponentService = costComponentService;
- this.individualLoanService = individualLoanService;
this.accountingAdapter = accountingAdapter;
}
@Transactional
@CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
- @EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = IndividualLoanEventConstants.OPEN_INDIVIDUALLOAN_CASE)
+ @EventEmitter(
+ selectorName = EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.OPEN_INDIVIDUALLOAN_CASE)
public IndividualLoanCommandEvent process(final OpenCommand command) {
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.OPEN);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.OPEN);
-
- final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
- individualLoanService.getCostComponentsForRepaymentPeriod(productIdentifier, dataContextOfAction.getCaseParameters(), BigDecimal.ZERO, Action.OPEN, today(), LocalDate.now());
-
+ final CostComponentsForRepaymentPeriod costComponents
+ = costComponentService.getCostComponentsForOpen(dataContextOfAction);
final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
= new DesignatorToAccountIdentifierMapper(dataContextOfAction);
- final List<ChargeInstance> charges = costComponentsForRepaymentPeriod.stream().map(x -> new ChargeInstance(
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getFromAccountDesignator()),
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getToAccountDesignator()),
- x.getValue().getAmount())).collect(Collectors.toList());
- //TODO: Accrual
+ final List<ChargeInstance> charges = costComponents.stream()
+ .map(x -> mapCostComponentEntryToChargeInstance(Action.OPEN, x, designatorToAccountIdentifierMapper))
+ .collect(Collectors.toList());
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
productIdentifier + "." + caseIdentifier + "." + Action.OPEN.name(),
Action.OPEN.getTransactionType());
//Only move to new state if book charges command was accepted.
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.PENDING);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.PENDING.name());
+ caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, Action.OPEN.name());
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
}
@Transactional
@CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
- @EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = IndividualLoanEventConstants.DENY_INDIVIDUALLOAN_CASE)
+ @EventEmitter(
+ selectorName = EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.DENY_INDIVIDUALLOAN_CASE)
public IndividualLoanCommandEvent process(final DenyCommand command) {
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
- final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DENY);
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.CLOSED);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier(), "x");
+ final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(
+ productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DENY);
+
+ final CostComponentsForRepaymentPeriod costComponents
+ = costComponentService.getCostComponentsForDeny(dataContextOfAction);
+
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+
+ final List<ChargeInstance> charges = costComponents.stream()
+ .map(x -> mapCostComponentEntryToChargeInstance(Action.DENY, x, designatorToAccountIdentifierMapper))
+ .collect(Collectors.toList());
+
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.CLOSED.name());
+ caseRepository.save(customerCase);
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
}
@Transactional
@CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
- @EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = IndividualLoanEventConstants.APPROVE_INDIVIDUALLOAN_CASE)
+ @EventEmitter(
+ selectorName = EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.APPROVE_INDIVIDUALLOAN_CASE)
public IndividualLoanCommandEvent process(final ApproveCommand command) {
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.APPROVE);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.APPROVE);
//TODO: Check for incomplete task instances.
@@ -139,14 +161,12 @@
);
caseRepository.save(dataContextOfAction.getCustomerCase());
- //Charge the approval fee if applicable.
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
- individualLoanService.getCostComponentsForRepaymentPeriod(productIdentifier, dataContextOfAction.getCaseParameters(), BigDecimal.ZERO, Action.APPROVE, today(), LocalDate.now());
+ costComponentService.getCostComponentsForApprove(dataContextOfAction);
- final List<ChargeInstance> charges = costComponentsForRepaymentPeriod.stream().map(x -> new ChargeInstance(
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getFromAccountDesignator()),
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getToAccountDesignator()),
- x.getValue().getAmount())).collect(Collectors.toList());
+ final List<ChargeInstance> charges = costComponentsForRepaymentPeriod.stream()
+ .map(x -> mapCostComponentEntryToChargeInstance(Action.APPROVE, x, designatorToAccountIdentifierMapper))
+ .collect(Collectors.toList());
accountingAdapter.bookCharges(charges,
command.getCommand().getNote(),
@@ -154,9 +174,11 @@
Action.APPROVE.getTransactionType());
//Only move to new state if book charges command was accepted.
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.APPROVED);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.APPROVED.name());
+ caseRepository.save(customerCase);
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, Action.APPROVE.name());
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
}
@Transactional
@@ -167,24 +189,19 @@
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(
productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DISBURSE);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.DISBURSE);
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
- individualLoanService.getCostComponentsForRepaymentPeriod(productIdentifier, dataContextOfAction.getCaseParameters(), BigDecimal.ZERO, Action.DISBURSE, today(), LocalDate.now());
-
+ costComponentService.getCostComponentsForDisburse(dataContextOfAction);
final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
= new DesignatorToAccountIdentifierMapper(dataContextOfAction);
- final List<ChargeInstance> charges = Stream.concat(costComponentsForRepaymentPeriod.stream().map(x -> new ChargeInstance(
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getFromAccountDesignator()),
- designatorToAccountIdentifierMapper.mapOrThrow(x.getKey().getToAccountDesignator()),
- x.getValue().getAmount())),
- Stream.of(new ChargeInstance(
- designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.PENDING_DISBURSAL),
- designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN),
- dataContextOfAction.getCaseParameters().getMaximumBalance())))
+ final BigDecimal disbursalAmount = dataContextOfAction.getCaseParameters().getMaximumBalance();
+ final List<ChargeInstance> charges = Stream.concat(
+ costComponentsForRepaymentPeriod.stream().map(x -> mapCostComponentEntryToChargeInstance(Action.DISBURSE, x, designatorToAccountIdentifierMapper)),
+ Stream.of(getDisbursalChargeInstance(disbursalAmount, designatorToAccountIdentifierMapper)))
.collect(Collectors.toList());
accountingAdapter.bookCharges(charges,
@@ -192,9 +209,50 @@
productIdentifier + "." + caseIdentifier + "." + Action.DISBURSE.name(),
Action.DISBURSE.getTransactionType());
//Only move to new state if book charges command was accepted.
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.ACTIVE);
+ if (Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()) != Case.State.ACTIVE) {
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ final LocalDateTime endOfTerm
+ = ScheduledActionHelpers.getRoughEndDate(LocalDate.now(ZoneId.of("UTC")), dataContextOfAction.getCaseParameters())
+ .atTime(LocalTime.MIDNIGHT);
+ customerCase.setEndOfTerm(endOfTerm);
+ customerCase.setCurrentState(Case.State.ACTIVE.name());
+ caseRepository.save(customerCase);
+ }
- return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier, Action.DISBURSE.name());
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
+ }
+
+ @Transactional
+ @CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
+ @EventEmitter(
+ selectorName = EventConstants.SELECTOR_NAME,
+ selectorValue = IndividualLoanEventConstants.APPLY_INTEREST_INDIVIDUALLOAN_CASE)
+ public IndividualLoanCommandEvent process(final ApplyInterestCommand command) {
+ final String productIdentifier = command.getProductIdentifier();
+ final String caseIdentifier = command.getCaseIdentifier();
+ final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(
+ productIdentifier, caseIdentifier, null);
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.APPLY_INTEREST);
+ if (dataContextOfAction.getCustomerCase().getEndOfTerm() == null)
+ throw ServiceException.internalError(
+ "End of term not set for active case ''{0}.{1}.''", productIdentifier, caseIdentifier);
+
+ final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
+ costComponentService.getCostComponentsForApplyInterest(dataContextOfAction);
+
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+
+ final List<ChargeInstance> charges = costComponentsForRepaymentPeriod.stream()
+ .map(x -> mapCostComponentEntryToChargeInstance(Action.APPLY_INTEREST, x, designatorToAccountIdentifierMapper))
+ .collect(Collectors.toList());
+
+ accountingAdapter.bookCharges(charges,
+ "",
+ productIdentifier + "." + caseIdentifier + "." + Action.APPLY_INTEREST.name(),
+ Action.APPLY_INTEREST.getTransactionType());
+
+ return new IndividualLoanCommandEvent(productIdentifier, caseIdentifier);
}
@Transactional
@@ -204,9 +262,11 @@
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.ACCEPT_PAYMENT);
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.ACTIVE);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier(), "x");
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.ACCEPT_PAYMENT);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.ACTIVE.name());
+ caseRepository.save(customerCase);
+ return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
}
@Transactional
@@ -216,9 +276,11 @@
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.WRITE_OFF);
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.CLOSED);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier(), "x");
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.WRITE_OFF);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.CLOSED.name());
+ caseRepository.save(customerCase);
+ return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
}
@Transactional
@@ -228,9 +290,11 @@
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.CLOSE);
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.CLOSED);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier(), "x");
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.CLOSE);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.CLOSED.name());
+ caseRepository.save(customerCase);
+ return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
}
@Transactional
@@ -240,22 +304,44 @@
final String productIdentifier = command.getProductIdentifier();
final String caseIdentifier = command.getCaseIdentifier();
final DataContextOfAction dataContextOfAction = costComponentService.checkedGetDataContext(productIdentifier, caseIdentifier, command.getCommand().getOneTimeAccountAssignments());
- checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.RECOVER);
- updateCaseState(dataContextOfAction.getCustomerCase(), Case.State.CLOSED);
- return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier(), "x");
- }
-
- private static LocalDate today() {
- return LocalDate.now(ZoneId.of("UTC"));
- }
-
- private void checkActionCanBeExecuted(final Case.State state, final Action action) {
- if (!IndividualLendingPatternFactory.getAllowedNextActionsForState(state).contains(action))
- throw ServiceException.badRequest("Cannot call action {0} from state {1}", action.name(), state.name());
- }
-
- private void updateCaseState(final CaseEntity customerCase, final Case.State state) {
- customerCase.setCurrentState(state.name());
+ IndividualLendingPatternFactory.checkActionCanBeExecuted(Case.State.valueOf(dataContextOfAction.getCustomerCase().getCurrentState()), Action.RECOVER);
+ final CaseEntity customerCase = dataContextOfAction.getCustomerCase();
+ customerCase.setCurrentState(Case.State.CLOSED.name());
caseRepository.save(customerCase);
+ return new IndividualLoanCommandEvent(command.getProductIdentifier(), command.getCaseIdentifier());
}
-}
+
+
+ private static ChargeInstance mapCostComponentEntryToChargeInstance(
+ final Action action,
+ final Map.Entry<ChargeDefinition, CostComponent> costComponentEntry,
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper) {
+ final ChargeDefinition chargeDefinition = costComponentEntry.getKey();
+ if (chargeDefinition.getAccrualAccountDesignator() != null) {
+ if (Action.valueOf(chargeDefinition.getAccrueAction()) == action)
+ return new ChargeInstance(
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getFromAccountDesignator()),
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getAccrualAccountDesignator()),
+ costComponentEntry.getValue().getAmount());
+ else
+ return new ChargeInstance(
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getToAccountDesignator()),
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getAccrualAccountDesignator()),
+ costComponentEntry.getValue().getAmount());
+ }
+ else
+ return new ChargeInstance(
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getFromAccountDesignator()),
+ designatorToAccountIdentifierMapper.mapOrThrow(chargeDefinition.getToAccountDesignator()),
+ costComponentEntry.getValue().getAmount());
+ }
+
+ private static ChargeInstance getDisbursalChargeInstance(
+ final BigDecimal amount,
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper) {
+ return new ChargeInstance(
+ designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.PENDING_DISBURSAL),
+ designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN),
+ amount);
+ }
+}
\ No newline at end of file
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 f3b4ade..bccade4 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
@@ -18,11 +18,12 @@
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.ChargeIdentifiers;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.individuallending.internal.mapper.CaseParametersMapper;
import io.mifos.individuallending.internal.repository.CaseParametersRepository;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
-import io.mifos.portfolio.api.v1.domain.Case;
+import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
import io.mifos.portfolio.service.internal.repository.CaseRepository;
@@ -36,15 +37,15 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
-import java.util.Collections;
-import java.util.List;
-import java.util.stream.Collectors;
+import java.util.*;
+import java.util.function.BiFunction;
/**
* @author Myrle Krantz
*/
@Service
public class CostComponentService {
+ private static final int RUNNING_CALCULATION_PRECISION = 8;
private final ProductRepository productRepository;
private final CaseRepository caseRepository;
@@ -88,21 +89,201 @@
return new DataContextOfAction(product, customerCase, caseParameters, oneTimeAccountAssignments);
}
- public List<CostComponent> getCostComponents(final String productIdentifier, final String caseIdentifier, final Action action) {
- final DataContextOfAction context = checkedGetDataContext(productIdentifier, caseIdentifier, Collections.emptyList());
- final Case.State caseState = Case.State.valueOf(context.getCustomerCase().getCurrentState());
- final BigDecimal runningBalance;
- if (caseState == Case.State.ACTIVE) {
- final DesignatorToAccountIdentifierMapper mapper = new DesignatorToAccountIdentifierMapper(context);
- final String customerLoanAccountIdentifier = mapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN);
- runningBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier);
+ public CostComponentsForRepaymentPeriod getCostComponentsForAction(
+ final Action action,
+ final DataContextOfAction dataContextOfAction) {
+ switch (action) {
+ case OPEN:
+ return getCostComponentsForOpen(dataContextOfAction);
+ case APPROVE:
+ return getCostComponentsForApprove(dataContextOfAction);
+ case DENY:
+ return getCostComponentsForDeny(dataContextOfAction);
+ case DISBURSE:
+ return getCostComponentsForDisburse(dataContextOfAction);
+ case APPLY_INTEREST:
+ return getCostComponentsForApplyInterest(dataContextOfAction);
+ case ACCEPT_PAYMENT:
+ return getCostComponentsForAcceptPayment(dataContextOfAction);
+ case CLOSE:
+ return getCostComponentsForClose(dataContextOfAction);
+ case MARK_LATE:
+ return getCostComponentsForMarkLate(dataContextOfAction);
+ case WRITE_OFF:
+ return getCostComponentsForMarkLate(dataContextOfAction);
+ case RECOVER:
+ return getCostComponentsForMarkLate(dataContextOfAction);
+ default:
+ throw ServiceException.internalError("Invalid action: ''{0}''.", action.name());
}
- else
- runningBalance = BigDecimal.ZERO;
+ }
- return individualLoanService.getCostComponentsForRepaymentPeriod(productIdentifier, context.getCaseParameters(), runningBalance, action, LocalDate.now(ZoneId.of("UTC")), LocalDate.now(ZoneId.of("UTC")))
- .stream()
- .map(x -> new CostComponent(x.getKey().getIdentifier(), x.getValue().getAmount()))
- .collect(Collectors.toList()); //TODO: initial disbursal date.
+ public CostComponentsForRepaymentPeriod getCostComponentsForOpen(final DataContextOfAction dataContextOfAction) {
+ final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
+ final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.OPEN, today()));
+ final List<ScheduledCharge> scheduledCharges = individualLoanService.getScheduledCharges(
+ productIdentifier, minorCurrencyUnitDigits, BigDecimal.ZERO, scheduledActions);
+
+ return getCostComponentsForScheduledCharges(
+ scheduledCharges,
+ caseParameters.getMaximumBalance(),
+ BigDecimal.ZERO,
+ minorCurrencyUnitDigits);
+ }
+
+ public CostComponentsForRepaymentPeriod getCostComponentsForDeny(final DataContextOfAction dataContextOfAction) {
+ final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
+ final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.DENY, today()));
+ final List<ScheduledCharge> scheduledCharges = individualLoanService.getScheduledCharges(
+ productIdentifier, minorCurrencyUnitDigits, BigDecimal.ZERO, scheduledActions);
+
+ return getCostComponentsForScheduledCharges(
+ scheduledCharges,
+ caseParameters.getMaximumBalance(),
+ BigDecimal.ZERO,
+ minorCurrencyUnitDigits);
+ }
+
+ 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 List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.APPROVE, today()));
+ final List<ScheduledCharge> scheduledCharges = individualLoanService.getScheduledCharges(
+ productIdentifier, minorCurrencyUnitDigits, BigDecimal.ZERO, scheduledActions);
+
+ return getCostComponentsForScheduledCharges(
+ scheduledCharges,
+ caseParameters.getMaximumBalance(),
+ BigDecimal.ZERO,
+ minorCurrencyUnitDigits);
+ }
+
+ public CostComponentsForRepaymentPeriod getCostComponentsForDisburse(final DataContextOfAction dataContextOfAction) {
+ final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
+ final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final List<ScheduledAction> scheduledActions = Collections.singletonList(new ScheduledAction(Action.DISBURSE, today()));
+ final List<ScheduledCharge> scheduledCharges = individualLoanService.getScheduledCharges(
+ productIdentifier, minorCurrencyUnitDigits, BigDecimal.ZERO, scheduledActions);
+
+ return getCostComponentsForScheduledCharges(
+ scheduledCharges,
+ caseParameters.getMaximumBalance(),
+ BigDecimal.ZERO,
+ minorCurrencyUnitDigits);
+ }
+
+ public CostComponentsForRepaymentPeriod getCostComponentsForApplyInterest(final DataContextOfAction dataContextOfAction) {
+
+ final DesignatorToAccountIdentifierMapper designatorToAccountIdentifierMapper
+ = new DesignatorToAccountIdentifierMapper(dataContextOfAction);
+ final String customerLoanAccountIdentifier = designatorToAccountIdentifierMapper.mapOrThrow(AccountDesignators.CUSTOMER_LOAN);
+ final BigDecimal currentBalance = accountingAdapter.getCurrentBalance(customerLoanAccountIdentifier);
+
+ final CaseParameters caseParameters = dataContextOfAction.getCaseParameters();
+ final String productIdentifier = dataContextOfAction.getProduct().getIdentifier();
+ final int minorCurrencyUnitDigits = dataContextOfAction.getProduct().getMinorCurrencyUnitDigits();
+ final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getScheduledActionsForDisbursedLoan(LocalDate.now(), dataContextOfAction.getCustomerCase().getEndOfTerm().toLocalDate(), caseParameters, Action.APPLY_INTEREST);
+ final List<ScheduledCharge> scheduledCharges = individualLoanService.getScheduledCharges(productIdentifier, minorCurrencyUnitDigits, currentBalance, scheduledActions);
+
+ return getCostComponentsForScheduledCharges(
+ scheduledCharges,
+ caseParameters.getMaximumBalance(),
+ currentBalance,
+ minorCurrencyUnitDigits);
+ }
+
+ private CostComponentsForRepaymentPeriod getCostComponentsForAcceptPayment(final DataContextOfAction dataContextOfAction) {
+ return null;
+ }
+ private CostComponentsForRepaymentPeriod getCostComponentsForMarkLate(final DataContextOfAction dataContextOfAction) {
+ return null;
+ }
+ public CostComponentsForRepaymentPeriod getCostComponentsForWriteOff(final DataContextOfAction dataContextOfAction) {
+ return null;
+ }
+ private CostComponentsForRepaymentPeriod getCostComponentsForClose(final DataContextOfAction dataContextOfAction) {
+ return null;
+ }
+ public CostComponentsForRepaymentPeriod getCostComponentsForRecover(final DataContextOfAction dataContextOfAction) {
+ return null;
+ }
+
+ static CostComponentsForRepaymentPeriod getCostComponentsForScheduledCharges(
+ final Collection<ScheduledCharge> scheduledCharges,
+ final BigDecimal maximumBalance,
+ final BigDecimal runningBalance,
+ final int minorCurrencyUnitDigits) {
+ BigDecimal balanceAdjustment = BigDecimal.ZERO;
+
+ final Map<ChargeDefinition, CostComponent> costComponentMap = new HashMap<>();
+ for (final ScheduledCharge scheduledCharge : scheduledCharges)
+ {
+ final CostComponent costComponent = costComponentMap
+ .computeIfAbsent(scheduledCharge.getChargeDefinition(),
+ chargeIdentifier -> {
+ final CostComponent ret = new CostComponent();
+ ret.setChargeIdentifier(scheduledCharge.getChargeDefinition().getIdentifier());
+ ret.setAmount(BigDecimal.ZERO);
+ return ret;
+ });
+
+ final BigDecimal chargeAmount = howToApplyScheduledChargeToBalance(scheduledCharge)
+ .apply(maximumBalance, runningBalance)
+ .setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
+ if (chargeDefinitionTouchesCustomerLoanAccount(scheduledCharge.getChargeDefinition()))
+ balanceAdjustment = balanceAdjustment.add(chargeAmount);
+ costComponent.setAmount(costComponent.getAmount().add(chargeAmount));
+ }
+
+ return new CostComponentsForRepaymentPeriod(
+ costComponentMap,
+ balanceAdjustment);
+ }
+
+ private static BiFunction<BigDecimal, BigDecimal, BigDecimal> howToApplyScheduledChargeToBalance(
+ final ScheduledCharge scheduledCharge)
+ {
+
+ switch (scheduledCharge.getChargeDefinition().getChargeMethod())
+ {
+ case FIXED:
+ return (maximumBalance, runningBalance) -> scheduledCharge.getChargeDefinition().getAmount();
+ case PROPORTIONAL: {
+ switch (scheduledCharge.getChargeDefinition().getProportionalTo()) {
+ case ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR:
+ return (maximumBalance, runningBalance) ->
+ PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, RUNNING_CALCULATION_PRECISION)
+ .multiply(runningBalance);
+ case ChargeIdentifiers.MAXIMUM_BALANCE_DESIGNATOR:
+ return (maximumBalance, runningBalance) ->
+ PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, RUNNING_CALCULATION_PRECISION)
+ .multiply(maximumBalance);
+ default:
+//TODO: correctly implement charges which are proportionate to other charges.
+ return (maximumBalance, runningBalance) ->
+ PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, RUNNING_CALCULATION_PRECISION)
+ .multiply(maximumBalance);
+ }
+ }
+ default:
+ return (maximumBalance, runningBalance) -> BigDecimal.ZERO;
+ }
+ }
+
+ private static boolean chargeDefinitionTouchesCustomerLoanAccount(final ChargeDefinition chargeDefinition)
+ {
+ return chargeDefinition.getToAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
+ chargeDefinition.getFromAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
+ (chargeDefinition.getAccrualAccountDesignator() != null && chargeDefinition.getAccrualAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN));
+ }
+ private static LocalDate today() {
+ return LocalDate.now(ZoneId.of("UTC"));
}
}
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 4167b81..e7288a2 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,13 +15,10 @@
*/
package io.mifos.individuallending.internal.service;
-import io.mifos.core.lang.DateConverter;
import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
import io.mifos.individuallending.api.v1.domain.caseinstance.ChargeName;
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPayment;
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPaymentPage;
-import io.mifos.individuallending.api.v1.domain.product.AccountDesignators;
-import io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
@@ -38,7 +35,6 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
-import java.util.function.BiFunction;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -57,17 +53,14 @@
private static final int EXTRA_PRECISION = 4;
private final ProductService productService;
private final ChargeDefinitionService chargeDefinitionService;
- private final ScheduledActionService scheduledActionService;
private final PeriodChargeCalculator periodChargeCalculator;
@Autowired
public IndividualLoanService(final ProductService productService,
final ChargeDefinitionService chargeDefinitionService,
- final ScheduledActionService scheduledActionService,
final PeriodChargeCalculator periodChargeCalculator) {
this.productService = productService;
this.chargeDefinitionService = chargeDefinitionService;
- this.scheduledActionService = scheduledActionService;
this.periodChargeCalculator = periodChargeCalculator;
}
@@ -81,7 +74,7 @@
.orElseThrow(() -> new IllegalArgumentException("Non-existent product identifier."));
final int minorCurrencyUnitDigits = product.getMinorCurrencyUnitDigits();
- final List<ScheduledAction> scheduledActions = scheduledActionService.getHypotheticalScheduledActions(initialDisbursalDate, caseParameters);
+ final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(initialDisbursalDate, caseParameters);
final List<ScheduledCharge> scheduledCharges = getScheduledCharges(productIdentifier, minorCurrencyUnitDigits, caseParameters.getMaximumBalance(), scheduledActions);
@@ -113,34 +106,15 @@
return ret;
}
- public CostComponentsForRepaymentPeriod getCostComponentsForRepaymentPeriod(final String productIdentifier,
- final CaseParameters caseParameters,
- final BigDecimal runningBalance,
- final Action action,
- final LocalDate initialDisbursalDate,
- final LocalDate forDate) {
- final Product product = productService.findByIdentifier(productIdentifier)
- .orElseThrow(() -> new IllegalArgumentException("Non-existent product identifier."));
- final int minorCurrencyUnitDigits = product.getMinorCurrencyUnitDigits();
- final List<ScheduledAction> scheduledActions = scheduledActionService.getScheduledActions(initialDisbursalDate, caseParameters, action, forDate);
- final List<ScheduledCharge> scheduledCharges = getScheduledCharges(productIdentifier, minorCurrencyUnitDigits, runningBalance, scheduledActions);
-
- return getCostComponentsForScheduledCharges(
- scheduledCharges,
- caseParameters.getMaximumBalance(),
- runningBalance,
- minorCurrencyUnitDigits);
- }
-
private static ChargeName chargeNameFromChargeDefinition(final ScheduledCharge scheduledCharge) {
return new ChargeName(scheduledCharge.getChargeDefinition().getIdentifier(), scheduledCharge.getChargeDefinition().getName());
}
- private List<ScheduledCharge> getScheduledCharges(
- final String productIdentifier,
- final int minorCurrencyUnitDigits,
- final BigDecimal initialBalance,
- final @Nonnull List<ScheduledAction> scheduledActions) {
+ List<ScheduledCharge> getScheduledCharges(
+ final String productIdentifier,
+ final int minorCurrencyUnitDigits,
+ final BigDecimal initialBalance,
+ final @Nonnull List<ScheduledAction> scheduledActions) {
final Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByChargeAction
= chargeDefinitionService.getChargeDefinitionsMappedByChargeAction(productIdentifier);
@@ -199,7 +173,13 @@
final List<ScheduledCharge> scheduledCharges) {
final Map<Period, SortedSet<ScheduledCharge>> orderedScheduledChargesGroupedByPeriod
= scheduledCharges.stream()
- .collect(Collectors.groupingBy(x -> x.getScheduledAction().repaymentPeriod,
+ .collect(Collectors.groupingBy(scheduledCharge -> {
+ final ScheduledAction scheduledAction = scheduledCharge.getScheduledAction();
+ if (ScheduledActionHelpers.actionHasNoActionPeriod(scheduledAction.action))
+ return new Period(null, null);
+ else
+ return scheduledAction.repaymentPeriod;
+ },
Collectors.mapping(x -> x,
Collector.of(
() -> new TreeSet<>(new ScheduledChargeComparator()),
@@ -207,8 +187,7 @@
(left, right) -> { left.addAll(right); return left; }))));
final SortedSet<Period> sortedRepaymentPeriods
- = scheduledCharges.stream()
- .map(x -> x.getScheduledAction().repaymentPeriod)
+ = orderedScheduledChargesGroupedByPeriod.keySet().stream()
.collect(Collector.of(TreeSet::new, TreeSet::add, (left, right) -> { left.addAll(right); return left; }));
BigDecimal balance = initialBalance.setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
@@ -217,11 +196,11 @@
{
final SortedSet<ScheduledCharge> scheduledChargesInPeriod = orderedScheduledChargesGroupedByPeriod.get(repaymentPeriod);
final CostComponentsForRepaymentPeriod costComponentsForRepaymentPeriod =
- getCostComponentsForScheduledCharges(scheduledChargesInPeriod, balance, balance, minorCurrencyUnitDigits);
+ CostComponentService.getCostComponentsForScheduledCharges(scheduledChargesInPeriod, balance, balance, minorCurrencyUnitDigits);
final PlannedPayment plannedPayment = new PlannedPayment();
- plannedPayment.setCostComponents(costComponentsForRepaymentPeriod.costComponents.values().stream().collect(Collectors.toList()));
- plannedPayment.setDate(DateConverter.toIsoString(repaymentPeriod.getEndDate()));
+ plannedPayment.setCostComponents(new ArrayList<>(costComponentsForRepaymentPeriod.costComponents.values()));
+ plannedPayment.setDate(repaymentPeriod.getEndDateAsString());
balance = balance.add(costComponentsForRepaymentPeriod.balanceAdjustment);
plannedPayment.setRemainingPrincipal(balance);
plannedPayments.add(plannedPayment);
@@ -239,67 +218,6 @@
return plannedPayments;
}
- static private CostComponentsForRepaymentPeriod getCostComponentsForScheduledCharges(
- final Collection<ScheduledCharge> scheduledCharges,
- final BigDecimal maximumBalance,
- final BigDecimal runningBalance,
- final int minorCurrencyUnitDigits) {
- BigDecimal balanceAdjustment = BigDecimal.ZERO;
-
- final Map<ChargeDefinition, CostComponent> costComponentMap = new HashMap<>();
- for (final ScheduledCharge scheduledCharge : scheduledCharges)
- {
- final CostComponent costComponent = costComponentMap
- .computeIfAbsent(scheduledCharge.getChargeDefinition(),
- chargeIdentifier -> {
- final CostComponent ret = new CostComponent();
- ret.setChargeIdentifier(scheduledCharge.getChargeDefinition().getIdentifier());
- ret.setAmount(BigDecimal.ZERO);
- return ret;
- });
-
- final BigDecimal chargeAmount = howToApplyScheduledChargeToBalance(scheduledCharge, 8)
- .apply(maximumBalance, runningBalance)
- .setScale(minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN);
- if (chargeDefinitionTouchesCustomerLoanAccount(scheduledCharge.getChargeDefinition()))
- balanceAdjustment = balanceAdjustment.add(chargeAmount);
- costComponent.setAmount(costComponent.getAmount().add(chargeAmount));
- }
-
- return new CostComponentsForRepaymentPeriod(
- costComponentMap,
- balanceAdjustment);
- }
-
- private static boolean chargeDefinitionTouchesCustomerLoanAccount(final ChargeDefinition chargeDefinition)
- {
- return chargeDefinition.getToAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
- chargeDefinition.getFromAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN) ||
- (chargeDefinition.getAccrualAccountDesignator() != null && chargeDefinition.getAccrualAccountDesignator().equals(AccountDesignators.CUSTOMER_LOAN));
- }
-
- private static BiFunction<BigDecimal, BigDecimal, BigDecimal> howToApplyScheduledChargeToBalance(
- final ScheduledCharge scheduledCharge,
- final int precision)
- {
-
- switch (scheduledCharge.getChargeDefinition().getChargeMethod())
- {
- case FIXED:
- return (maximumBalance, runningBalance) -> scheduledCharge.getChargeDefinition().getAmount();
- case PROPORTIONAL: {
- if (scheduledCharge.getChargeDefinition().getProportionalTo().equals(ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR))
- return (maximumBalance, runningBalance) -> PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, precision).multiply(runningBalance);
- else if (scheduledCharge.getChargeDefinition().getProportionalTo().equals(ChargeIdentifiers.MAXIMUM_BALANCE_DESIGNATOR))
- return (maximumBalance, runningBalance) -> PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, precision).multiply(maximumBalance);
- else //TODO: correctly implement charges which are proportionate to other charges.
- return (maximumBalance, runningBalance) -> PeriodChargeCalculator.chargeAmountPerPeriod(scheduledCharge, precision).multiply(maximumBalance);
- }
- default:
- return (maximumBalance, runningBalance) -> BigDecimal.ZERO;
- }
- }
-
private BigDecimal loanPaymentInContextOfAccruedInterest(
final BigDecimal initialBalance,
final int periodCount,
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 aa4f170..ecd04f2 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
@@ -15,6 +15,8 @@
*/
package io.mifos.individuallending.internal.service;
+import io.mifos.core.lang.DateConverter;
+
import javax.annotation.Nonnull;
import java.time.Duration;
import java.time.LocalDate;
@@ -46,6 +48,10 @@
return endDate;
}
+ String getEndDateAsString() {
+ return endDate == null ? null : DateConverter.toIsoString(endDate);
+ }
+
Duration getDuration() {
long days = beginDate.until(endDate, ChronoUnit.DAYS);
return ChronoUnit.DAYS.getDuration().multipliedBy(days);
@@ -71,13 +77,26 @@
@Override
public int compareTo(@Nonnull Period o) {
- int comparison = endDate.compareTo(o.endDate);
+ final int comparison = compareNullableDates(endDate, o.endDate);
+
if (comparison == 0)
- return beginDate.compareTo(o.beginDate);
+ return compareNullableDates(beginDate, o.beginDate);
else
return comparison;
}
+ @SuppressWarnings("ConstantConditions")
+ private static int compareNullableDates(final LocalDate x, final LocalDate y) {
+ if ((x == null) && (y == null))
+ return 0;
+ else if ((x == null) && (y != null))
+ return -1;
+ else if ((x != null) && (y == null))
+ return 1;
+ else
+ return x.compareTo(y);
+ }
+
@Override
public String toString() {
return "Period{" +
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 7f5b57a..30c77fe 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
@@ -18,17 +18,18 @@
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.time.LocalDate;
import java.util.Objects;
/**
* @author Myrle Krantz
*/
-class ScheduledAction {
+public class ScheduledAction {
final Action action;
final LocalDate when;
- final Period actionPeriod;
- final Period repaymentPeriod;
+ final @Nullable Period actionPeriod;
+ final @Nullable Period repaymentPeriod;
ScheduledAction(@Nonnull final Action action,
@Nonnull final LocalDate when,
@@ -40,6 +41,14 @@
this.repaymentPeriod = repaymentPeriod;
}
+ ScheduledAction(@Nonnull final Action action,
+ @Nonnull final LocalDate when) {
+ this.action = action;
+ this.when = when;
+ this.actionPeriod = null;
+ this.repaymentPeriod = null;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionService.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
similarity index 64%
rename from service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionService.java
rename to service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
index 87a126e..80dcf48 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionService.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledActionHelpers.java
@@ -18,12 +18,12 @@
import io.mifos.portfolio.api.v1.domain.PaymentCycle;
import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
-import org.springframework.stereotype.Service;
import javax.annotation.Nonnull;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.YearMonth;
+import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.SortedSet;
@@ -35,36 +35,55 @@
* @author Myrle Krantz
*/
@SuppressWarnings("WeakerAccess")
-@Service
-public class ScheduledActionService {
-
- List<ScheduledAction> getHypotheticalScheduledActions(final @Nonnull LocalDate initialDisbursalDate,
- final @Nonnull CaseParameters caseParameters)
- {
- return getHypotheticalScheduledActionsHelper(initialDisbursalDate, caseParameters).collect(Collectors.toList());
+public class ScheduledActionHelpers {
+ public static boolean actionHasNoActionPeriod(final Action action) {
+ return preDisbursalActions().anyMatch(x -> action == x);
}
- private Stream<ScheduledAction> getHypotheticalScheduledActionsHelper(final @Nonnull LocalDate initialDisbursalDate,
+ private static Stream<Action> preDisbursalActions() {
+ return Stream.of(Action.OPEN, Action.APPROVE, Action.DISBURSE);
+ }
+
+ public static List<ScheduledAction> getHypotheticalScheduledActions(final @Nonnull LocalDate initialDisbursalDate,
final @Nonnull CaseParameters caseParameters)
{
- //'Rough' end date, because if the repayment period takes the last period after that end date, then the repayment
- // period will 'win'.
- final LocalDate roughEndDate = getEndDate(caseParameters, initialDisbursalDate);
-
- final SortedSet<Period> repaymentPeriods = generateRepaymentPeriods(initialDisbursalDate, roughEndDate, caseParameters);
- final Period firstPeriod = repaymentPeriods.first();
- final Period lastPeriod = repaymentPeriods.last();
-
- return Stream.concat(Stream.of(
- new ScheduledAction(Action.OPEN, initialDisbursalDate, firstPeriod, firstPeriod),
- new ScheduledAction(Action.APPROVE, initialDisbursalDate, firstPeriod, firstPeriod),
- new ScheduledAction(Action.DISBURSE, initialDisbursalDate, firstPeriod, firstPeriod)),
- Stream.concat(repaymentPeriods.stream().flatMap(this::generateScheduledActionsForRepaymentPeriod),
- Stream.of(new ScheduledAction(Action.CLOSE, lastPeriod.getEndDate(), lastPeriod, lastPeriod))));
+ final LocalDate endOfTerm = getRoughEndDate(initialDisbursalDate, caseParameters);
+ return Stream.concat(preDisbursalActions().map(action -> new ScheduledAction(action, initialDisbursalDate)),
+ getHypotheticalScheduledActionsForDisbursedLoan(initialDisbursalDate, endOfTerm, caseParameters))
+ .collect(Collectors.toList());
}
- private LocalDate getEndDate(final @Nonnull CaseParameters caseParameters,
- final @Nonnull LocalDate initialDisbursalDate) {
+ public static List<ScheduledAction> getScheduledActionsForDisbursedLoan(final @Nonnull LocalDate forDate,
+ final @Nonnull LocalDate endOfTerm,
+ final @Nonnull CaseParameters caseParameters,
+ final @Nonnull Action action) {
+ if (preDisbursalActions().anyMatch(x -> action == x))
+ throw new IllegalStateException("Should not be calling getScheduledActionsForDisbursedLoan with an action which occurs before disbursement.");
+
+ final LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ return getHypotheticalScheduledActionsForDisbursedLoan(today, endOfTerm, caseParameters)
+ .filter(x -> x.action.equals(action))
+ .filter(x -> x.actionPeriod != null && x.actionPeriod.containsDate(forDate))
+ .collect(Collectors.toList());
+ }
+
+ private static Stream<ScheduledAction> getHypotheticalScheduledActionsForDisbursedLoan(
+ final @Nonnull LocalDate initialDisbursalDate,
+ final @Nonnull LocalDate endOfTerm,
+ final @Nonnull CaseParameters caseParameters)
+ {
+ final SortedSet<Period> repaymentPeriods = generateRepaymentPeriods(initialDisbursalDate, endOfTerm, caseParameters);
+ final Period lastPeriod = repaymentPeriods.last();
+
+ return Stream.concat(repaymentPeriods.stream().flatMap(ScheduledActionHelpers::generateScheduledActionsForRepaymentPeriod),
+ Stream.of(new ScheduledAction(Action.CLOSE, lastPeriod.getEndDate(), lastPeriod, lastPeriod)));
+ }
+
+ /** 'Rough' end date, because if the repayment period takes the last period after that end date, then the repayment
+ period will 'win'.*/
+
+ public static LocalDate getRoughEndDate(final @Nonnull LocalDate initialDisbursalDate,
+ final @Nonnull CaseParameters caseParameters) {
final Integer maximumTermSize = caseParameters.getTermRange().getMaximum();
final ChronoUnit termUnit = caseParameters.getTermRange().getTemporalUnit();
@@ -73,22 +92,22 @@
termUnit);
}
- private Stream<ScheduledAction> generateScheduledActionsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
+ private static Stream<ScheduledAction> generateScheduledActionsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
return Stream.concat(generateScheduledInterestPaymentsForRepaymentPeriod(repaymentPeriod),
Stream.of(new ScheduledAction(Action.ACCEPT_PAYMENT, repaymentPeriod.getEndDate(), repaymentPeriod, repaymentPeriod)));
}
- private Stream<ScheduledAction> generateScheduledInterestPaymentsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
+ private static Stream<ScheduledAction> generateScheduledInterestPaymentsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
return getInterestDayInRepaymentPeriod(repaymentPeriod).map(x ->
new ScheduledAction(Action.APPLY_INTEREST, x, new Period(x.minus(1, ChronoUnit.DAYS), x), repaymentPeriod));
}
- private Stream<LocalDate> getInterestDayInRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
+ private static Stream<LocalDate> getInterestDayInRepaymentPeriod(final @Nonnull Period repaymentPeriod) {
return Stream.iterate(repaymentPeriod.getBeginDate().plusDays(1), date -> date.plusDays(1))
.limit(ChronoUnit.DAYS.between(repaymentPeriod.getBeginDate(), repaymentPeriod.getEndDate()));
}
- private SortedSet<Period> generateRepaymentPeriods(
+ private static SortedSet<Period> generateRepaymentPeriods(
final LocalDate initialDisbursalDate,
final LocalDate endDate,
final CaseParameters caseParameters) {
@@ -108,7 +127,7 @@
return ret;
}
- private LocalDate generateNextPaymentDate(final CaseParameters caseParameters, final LocalDate lastPaymentDate) {
+ private static LocalDate generateNextPaymentDate(final CaseParameters caseParameters, final LocalDate lastPaymentDate) {
final PaymentCycle paymentCycle = caseParameters.getPaymentCycle();
final ChronoUnit maximumSpecifiedAlignmentChronoUnit =
@@ -131,13 +150,13 @@
return alignPaymentDate(orientedPaymentDate, maximumAlignmentChronoUnit, paymentCycle);
}
- private LocalDate incrementPaymentDate(LocalDate paymentDate, PaymentCycle paymentCycle) {
+ private static LocalDate incrementPaymentDate(LocalDate paymentDate, PaymentCycle paymentCycle) {
return paymentDate.plus(
paymentCycle.getPeriod(),
paymentCycle.getTemporalUnit());
}
- private LocalDate orientPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumSpecifiedAlignmentChronoUnit, PaymentCycle paymentCycle) {
+ private static LocalDate orientPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumSpecifiedAlignmentChronoUnit, PaymentCycle paymentCycle) {
if (maximumSpecifiedAlignmentChronoUnit == ChronoUnit.HOURS)
return paymentDate; //No need to orient at all since no alignment is specified.
@@ -155,28 +174,28 @@
}
}
- private @Nonnull ChronoUnit min(@Nonnull final ChronoUnit a, @Nonnull final ChronoUnit b) {
+ private static @Nonnull ChronoUnit min(@Nonnull final ChronoUnit a, @Nonnull final ChronoUnit b) {
if (a.getDuration().compareTo(b.getDuration()) < 0)
return a;
else
return b;
}
- private LocalDate orientInYear(final LocalDate paymentDate) {
+ private static LocalDate orientInYear(final LocalDate paymentDate) {
return LocalDate.of(paymentDate.getYear(), 1, 1);
}
- private LocalDate orientInMonth(final LocalDate paymentDate) {
+ private static LocalDate orientInMonth(final LocalDate paymentDate) {
return LocalDate.of(paymentDate.getYear(), paymentDate.getMonth(), 1);
}
- private LocalDate orientInWeek(final LocalDate paymentDate) {
+ private static LocalDate orientInWeek(final LocalDate paymentDate) {
final DayOfWeek dayOfWeek = paymentDate.getDayOfWeek();
final int dayOfWeekIndex = dayOfWeek.getValue() - 1;
return paymentDate.minusDays(dayOfWeekIndex);
}
- private LocalDate alignPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumAlignmentChronoUnit, final PaymentCycle paymentCycle) {
+ private static LocalDate alignPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumAlignmentChronoUnit, final PaymentCycle paymentCycle) {
LocalDate ret = paymentDate;
switch (maximumAlignmentChronoUnit)
{
@@ -192,7 +211,7 @@
}
}
- private LocalDate alignInMonths(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
+ private static LocalDate alignInMonths(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
final Integer alignmentMonth = paymentCycle.getAlignmentMonth();
if (alignmentMonth == null)
return paymentDate;
@@ -200,7 +219,7 @@
return paymentDate.plusMonths(alignmentMonth);
}
- private LocalDate alignInWeeks(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
+ private static LocalDate alignInWeeks(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
final Integer alignmentWeek = paymentCycle.getAlignmentWeek();
if (alignmentWeek == null)
return paymentDate;
@@ -220,7 +239,7 @@
throw new IllegalStateException("PaymentCycle.alignmentWeek should only ever be 0, 1, 2, or -1.");
}
- private LocalDate alignInDays(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
+ static private LocalDate alignInDays(final LocalDate paymentDate, final PaymentCycle paymentCycle) {
final Integer alignmentDay = paymentCycle.getAlignmentDay();
if (alignmentDay == null)
return paymentDate;
@@ -231,7 +250,7 @@
return alignInDaysOfMonth(paymentDate, alignmentDay);
}
- private LocalDate alignInDaysOfWeek(final LocalDate paymentDate, final Integer alignmentDay) {
+ static private LocalDate alignInDaysOfWeek(final LocalDate paymentDate, final Integer alignmentDay) {
final int dayOfWeek = paymentDate.getDayOfWeek().getValue()-1;
if (dayOfWeek < alignmentDay)
@@ -242,18 +261,8 @@
return paymentDate;
}
- private LocalDate alignInDaysOfMonth(final LocalDate paymentDate, final Integer alignmentDay) {
+ private static LocalDate alignInDaysOfMonth(final LocalDate paymentDate, final Integer alignmentDay) {
final int maxDay = YearMonth.of(paymentDate.getYear(), paymentDate.getMonth()).lengthOfMonth()-1;
return paymentDate.plusDays(Math.min(maxDay, alignmentDay));
}
-
- public List<ScheduledAction> getScheduledActions(final @Nonnull LocalDate initialDisbursalDate,
- final CaseParameters caseParameters,
- final Action action,
- final LocalDate time) {
- return getHypotheticalScheduledActionsHelper(initialDisbursalDate, caseParameters)
- .filter(x -> x.actionPeriod.containsDate(time))
- .filter(x -> x.action.equals(action))
- .collect(Collectors.toList());
- }
}
\ No newline at end of file
diff --git a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledCharge.java b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledCharge.java
index 15cbf30..60fb56c 100644
--- a/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledCharge.java
+++ b/service/src/main/java/io/mifos/individuallending/internal/service/ScheduledCharge.java
@@ -22,7 +22,7 @@
/**
* @author Myrle Krantz
*/
-class ScheduledCharge {
+public class ScheduledCharge {
private final ScheduledAction scheduledAction;
private final ChargeDefinition chargeDefinition;
diff --git a/service/src/main/java/io/mifos/portfolio/service/rest/BeatPublishListenerRestController.java b/service/src/main/java/io/mifos/individuallending/rest/BeatPublishListenerRestController.java
similarity index 97%
rename from service/src/main/java/io/mifos/portfolio/service/rest/BeatPublishListenerRestController.java
rename to service/src/main/java/io/mifos/individuallending/rest/BeatPublishListenerRestController.java
index ef7bda8..4da6b4b 100644
--- a/service/src/main/java/io/mifos/portfolio/service/rest/BeatPublishListenerRestController.java
+++ b/service/src/main/java/io/mifos/individuallending/rest/BeatPublishListenerRestController.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package io.mifos.portfolio.service.rest;
+package io.mifos.individuallending.rest;
import io.mifos.anubis.annotation.AcceptedTokenType;
import io.mifos.anubis.annotation.Permittable;
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 98936f6..25cf294 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
@@ -16,6 +16,7 @@
package io.mifos.portfolio.service.config;
import io.mifos.core.lang.validation.constraints.ValidIdentifier;
+import org.hibernate.validator.constraints.Range;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
@@ -31,6 +32,9 @@
@ValidIdentifier
private String bookInterestAsUser;
+ @Range(min=0, max=23)
+ private int bookInterestInTimeSlot = 0;
+
public PortfolioProperties() {
}
@@ -41,4 +45,12 @@
public void setBookInterestAsUser(String bookInterestAsUser) {
this.bookInterestAsUser = bookInterestAsUser;
}
+
+ public int getBookInterestInTimeSlot() {
+ return bookInterestInTimeSlot;
+ }
+
+ public void setBookInterestInTimeSlot(int bookInterestInTimeSlot) {
+ this.bookInterestInTimeSlot = bookInterestInTimeSlot;
+ }
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/BeatPublishCommandHandler.java b/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/BeatPublishCommandHandler.java
deleted file mode 100644
index d879094..0000000
--- a/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/BeatPublishCommandHandler.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2017 The Mifos Initiative.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package io.mifos.portfolio.service.internal.command.handler;
-
-import io.mifos.core.command.annotation.Aggregate;
-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.ApplicationName;
-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 io.mifos.portfolio.service.internal.command.CreateBeatPublishCommand;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * @author Myrle Krantz
- */
-@SuppressWarnings("unused")
-@Aggregate
-public class BeatPublishCommandHandler {
-
- private final ApplicationName applicationName;
-
- @Autowired
- public BeatPublishCommandHandler(final ApplicationName applicationName) {
- this.applicationName = applicationName;
- }
-
- @Transactional
- @CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
- @EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = EventConstants.POST_PUBLISHEDBEAT)
- public BeatPublishEvent process(final CreateBeatPublishCommand createBeatPublishCommand) {
- final BeatPublish instance = createBeatPublishCommand.getInstance();
- return new BeatPublishEvent(applicationName.toString(), instance.getIdentifier(), instance.getForTime());
- }
-}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/InitializeCommandHandler.java b/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/InitializeCommandHandler.java
index ab3eff1..1ff341c 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/InitializeCommandHandler.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/command/handler/InitializeCommandHandler.java
@@ -23,6 +23,7 @@
import io.mifos.portfolio.api.v1.events.EventConstants;
import io.mifos.portfolio.service.internal.command.InitializeServiceCommand;
import io.mifos.portfolio.service.internal.util.RhythmAdapter;
+import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Autowired;
import javax.sql.DataSource;
@@ -51,7 +52,9 @@
@CommandHandler(logStart = CommandLogLevel.INFO, logFinish = CommandLogLevel.INFO)
@EventEmitter(selectorName = EventConstants.SELECTOR_NAME, selectorValue = EventConstants.INITIALIZE)
public String initialize(final InitializeServiceCommand initializeServiceCommand) {
- this.flywayFactoryBean.create(this.dataSource).migrate();
+ final Flyway flyway = this.flywayFactoryBean.create(this.dataSource);
+ System.out.println("Baseline version: " + flyway.getBaselineVersion());
+ flyway.migrate();
rhythmAdapter.request24Beats();
return EventConstants.INITIALIZE;
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ProductMapper.java b/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ProductMapper.java
index 27389f1..4e470b3 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ProductMapper.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/mapper/ProductMapper.java
@@ -22,6 +22,7 @@
import io.mifos.portfolio.service.internal.repository.ProductEntity;
import io.mifos.portfolio.service.internal.util.AccountingAdapter;
+import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
@@ -41,7 +42,7 @@
new TermRange(productEntity.getTermRangeTemporalUnit(),
productEntity.getTermRangeMaximum()));
product.setBalanceRange(
- new BalanceRange(productEntity.getBalanceRangeMinimum(), productEntity.getBalanceRangeMaximum()));
+ new BalanceRange(productEntity.getBalanceRangeMinimum().setScale(productEntity.getMinorCurrencyUnitDigits(), BigDecimal.ROUND_HALF_EVEN), productEntity.getBalanceRangeMaximum().setScale(productEntity.getMinorCurrencyUnitDigits(), BigDecimal.ROUND_HALF_EVEN)));
product.setInterestRange(
new InterestRange(productEntity.getInterestRangeMinimum(), productEntity.getInterestRangeMaximum()));
product.setInterestBasis(productEntity.getInterestBasis());
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseEntity.java b/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseEntity.java
index 6f71a1f..f86092f 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseEntity.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseEntity.java
@@ -17,6 +17,7 @@
import io.mifos.core.mariadb.util.LocalDateTimeConverter;
+import javax.annotation.Nullable;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.Set;
@@ -33,18 +34,22 @@
@Column(name = "id")
private Long id;
- @Column(name = "identifier")
+ @Column(name = "identifier", nullable = false)
private String identifier;
- @Column(name = "product_identifier")
+ @Column(name = "product_identifier", nullable = false)
private String productIdentifier;
@OneToMany(targetEntity = CaseAccountAssignmentEntity.class, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "caseEntity")
private Set<CaseAccountAssignmentEntity> accountAssignments;
- @Column(name = "current_state")
+ @Column(name = "current_state", nullable = false)
private String currentState;
+ @Column(name = "end_of_term")
+ @Convert(converter = LocalDateTimeConverter.class)
+ @Nullable private LocalDateTime endOfTerm;
+
@Column(name = "created_on")
@Convert(converter = LocalDateTimeConverter.class)
private LocalDateTime createdOn;
@@ -102,6 +107,14 @@
this.currentState = currentState;
}
+ public LocalDateTime getEndOfTerm() {
+ return endOfTerm;
+ }
+
+ public void setEndOfTerm(LocalDateTime endOfTerm) {
+ this.endOfTerm = endOfTerm;
+ }
+
public LocalDateTime getCreatedOn() {
return createdOn;
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseRepository.java b/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseRepository.java
index b83c890..2d439f6 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseRepository.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/repository/CaseRepository.java
@@ -24,6 +24,7 @@
import java.util.Collection;
import java.util.Optional;
+import java.util.stream.Stream;
/**
* @author Myrle Krantz
@@ -37,4 +38,6 @@
@Query("SELECT COUNT(t) > 0 FROM CaseEntity t WHERE t.productIdentifier = :productIdentifier")
boolean existsByProductIdentifier(@Param("productIdentifier") String productIdentifier);
+ Stream<CaseEntity> findByCurrentStateIn(Collection<String> currentStates);
+
}
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 f88a277..755640f 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
@@ -21,6 +21,7 @@
import io.mifos.accounting.api.v1.domain.*;
import io.mifos.core.api.util.UserContextHolder;
import io.mifos.core.lang.DateConverter;
+import io.mifos.core.lang.ServiceException;
import io.mifos.portfolio.api.v1.domain.AccountAssignment;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import org.apache.commons.lang.RandomStringUtils;
@@ -29,10 +30,7 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
-import java.util.Collection;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
+import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -102,8 +100,13 @@
}
public BigDecimal getCurrentBalance(final String accountIdentifier) {
- final Account account = ledgerManager.findAccount(accountIdentifier);
- return BigDecimal.valueOf(account.getBalance());
+ try {
+ final Account account = ledgerManager.findAccount(accountIdentifier);
+ return BigDecimal.valueOf(account.getBalance());
+ }
+ catch (final AccountNotFoundException e) {
+ throw ServiceException.internalError("Could not found the account with the identifier ''{0}''", accountIdentifier);
+ }
}
public String createAccountForLedgerAssignment(final String customerIdentifier, final AccountAssignment ledgerAssignment) {
@@ -138,7 +141,7 @@
public static Set<String> getRequiredAccountDesignators(final Collection<ChargeDefinition> chargeDefinitionEntities) {
return chargeDefinitionEntities.stream()
.flatMap(AccountingAdapter::getAutomaticActionAccountDesignators)
- .filter(x -> x != null)
+ .filter(Objects::nonNull)
.collect(Collectors.toSet());
}
diff --git a/service/src/main/java/io/mifos/portfolio/service/rest/ChargeDefinitionRestController.java b/service/src/main/java/io/mifos/portfolio/service/rest/ChargeDefinitionRestController.java
index aefab79..d460ea7 100644
--- a/service/src/main/java/io/mifos/portfolio/service/rest/ChargeDefinitionRestController.java
+++ b/service/src/main/java/io/mifos/portfolio/service/rest/ChargeDefinitionRestController.java
@@ -112,8 +112,8 @@
@RequestMapping(
value = "{chargedefinitionidentifier}",
method = RequestMethod.PUT,
- consumes = MediaType.APPLICATION_JSON_VALUE,
- produces = MediaType.ALL_VALUE
+ consumes = MediaType.ALL_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Void> changeChargeDefinition(
@PathVariable("productidentifier") final String productIdentifier,
diff --git a/service/src/main/resources/application.yml b/service/src/main/resources/application.yml
index a15f8b7..4493358 100644
--- a/service/src/main/resources/application.yml
+++ b/service/src/main/resources/application.yml
@@ -83,4 +83,5 @@
modulus: 21188023007955682867939457181271038457216099278949187456460742046123672432355777599460689470319454021384777684967830053993002724303461144745107517305075315187397862430851722919529943465029389248042840364475999768651348557757734298942211509744303551097953258597691851996692366468761965138767429272032120029271744611798874201312092155969603381492096789028306859853929900848124928201000469425135976322303229632628092728624143573273277870884919055453251617011673264035045823652246768583219018126865521694880333238485410601803458379987829318615730229086183405850999386270584135805252231189505197494383178133769189765423639
portfolio:
- bookInterestAsUser: interest_user
\ No newline at end of file
+ bookInterestAsUser: interest_user
+ bookInterestInTimeSlot: 0
\ No newline at end of file
diff --git a/service/src/main/resources/db/migrations/mariadb/V2__in_motion.sql b/service/src/main/resources/db/migrations/mariadb/V2__in_motion.sql
index f094533..0bcfebc 100644
--- a/service/src/main/resources/db/migrations/mariadb/V2__in_motion.sql
+++ b/service/src/main/resources/db/migrations/mariadb/V2__in_motion.sql
@@ -16,4 +16,6 @@
# noinspection SqlNoDataSourceInspectionForFile
-ALTER TABLE bastet_p_chrg_defs ADD COLUMN proportional_to VARCHAR(32) NULL;
\ No newline at end of file
+ALTER TABLE bastet_p_chrg_defs ADD COLUMN proportional_to VARCHAR(32) NULL DEFAULT NULL;
+
+ALTER TABLE bastet_cases ADD COLUMN end_of_term TIMESTAMP(3) NULL DEFAULT NULL;
\ No newline at end of file
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 70b5b34..de3c107 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
@@ -251,7 +251,7 @@
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitionsMappedByAction(chargeDefinitionsMappedByAction)
- .expectedChargeIdentifiers(new HashSet<>(Arrays.asList(PROCESSING_FEE_ID, LOAN_FUNDS_ALLOCATION_ID, RETURN_DISBURSEMENT_ID, LOAN_ORIGINATION_FEE_ID, INTEREST_ID, PAYMENT_ID)));
+ .expectedChargeIdentifiers(new HashSet<>(Arrays.asList(PROCESSING_FEE_ID, LOAN_FUNDS_ALLOCATION_ID, RETURN_DISBURSEMENT_ID, LOAN_ORIGINATION_FEE_ID, INTEREST_ID, DISBURSEMENT_FEE_ID, PAYMENT_ID)));
}
private static List<ChargeDefinition> getInterestChargeDefinition(final double amount, final ChronoUnit forCycleSizeUnit) {
@@ -317,7 +317,7 @@
Mockito.doReturn(Optional.of(product)).when(productServiceMock).findByIdentifier(testCase.productIdentifier);
Mockito.doReturn(testCase.chargeDefinitionsMappedByAction).when(chargeDefinitionServiceMock).getChargeDefinitionsMappedByChargeAction(testCase.productIdentifier);
- testSubject = new IndividualLoanService(productServiceMock, chargeDefinitionServiceMock, new ScheduledActionService(), new PeriodChargeCalculator());
+ testSubject = new IndividualLoanService(productServiceMock, chargeDefinitionServiceMock, new PeriodChargeCalculator());
}
@Test
@@ -357,9 +357,13 @@
});
//All customer payments should be within one percent of each other.
- final Set<BigDecimal> customerPayments = allPlannedPayments.stream().map(this::getCustomerPayment).collect(Collectors.toSet());
- final Optional<BigDecimal> maxPayment = customerPayments.stream().collect(Collectors.maxBy(BigDecimal::compareTo));
- final Optional<BigDecimal> minPayment = customerPayments.stream().collect(Collectors.minBy(BigDecimal::compareTo));
+ final Set<BigDecimal> customerRepayments = allPlannedPayments.stream()
+ .map(this::getCustomerRepayment)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toSet());
+ final Optional<BigDecimal> maxPayment = customerRepayments.stream().max(BigDecimal::compareTo);
+ final Optional<BigDecimal> minPayment = customerRepayments.stream().min(BigDecimal::compareTo);
Assert.assertTrue(maxPayment.isPresent());
Assert.assertTrue(minPayment.isPresent());
final double percentDifference = percentDifference(maxPayment.get(), minPayment.get());
@@ -377,36 +381,17 @@
Assert.assertEquals(testCase.expectedChargeIdentifiers, resultChargeIdentifiers);
}
- @Test
- public void getCostComponentsForRepaymentPeriod() {
- testCase.chargeInstancesForActions.entrySet().forEach(entry ->
- Assert.assertEquals(
- entry.getValue().stream().collect(Collectors.toSet()),
- testSubject.getCostComponentsForRepaymentPeriod(
- testCase.productIdentifier,
- testCase.caseParameters,
- testCase.caseParameters.getMaximumBalance(),
- entry.getKey().getAction(),
- testCase.initialDisbursementDate, entry.getKey().getLocalDate())
- .stream()
- .map(x -> new ChargeInstance(x.getKey().getFromAccountDesignator(), x.getKey().getToAccountDesignator(), x.getValue().getAmount()))
- .collect(Collectors.toSet())
- ));
- }
-
private double percentDifference(final BigDecimal maxPayment, final BigDecimal minPayment) {
final BigDecimal difference = maxPayment.subtract(minPayment);
final BigDecimal percentDifference = difference.divide(maxPayment, 4, BigDecimal.ROUND_UP);
return percentDifference.doubleValue();
}
- private BigDecimal getCustomerPayment(final PlannedPayment plannedPayment) {
+ private Optional<BigDecimal> getCustomerRepayment(final PlannedPayment plannedPayment) {
final Optional<CostComponent> ret = plannedPayment.getCostComponents().stream()
.filter(y -> y.getChargeIdentifier().equals(ChargeIdentifiers.PAYMENT_ID))
.findAny();
- Assert.assertTrue(ret.isPresent());
-
- return ret.get().getAmount().abs();
+ return ret.map(x -> x.getAmount().abs());
}
}
diff --git a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionServiceTest.java b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java
similarity index 94%
rename from service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionServiceTest.java
rename to service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java
index 1cc2fb6..9c3b265 100644
--- a/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionServiceTest.java
+++ b/service/src/test/java/io/mifos/individuallending/internal/service/ScheduledActionHelperTest.java
@@ -35,7 +35,7 @@
* @author Myrle Krantz
*/
@RunWith(Parameterized.class)
-public class ScheduledActionServiceTest {
+public class ScheduledActionHelperTest {
private static class TestCase
{
final String description;
@@ -355,17 +355,17 @@
private final TestCase testCase;
- public ScheduledActionServiceTest(final TestCase testCase)
+ public ScheduledActionHelperTest(final TestCase testCase)
{
this.testCase = testCase;
}
@Test
public void getScheduledActions() throws Exception {
- final ScheduledActionService testSubject = new ScheduledActionService();
- final List<ScheduledAction> result = testSubject.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
+ final List<ScheduledAction> result = ScheduledActionHelpers.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
- Assert.assertTrue(testCase.description, result.containsAll(testCase.expectedResultContents));
+ Assert.assertTrue("Case " + testCase.description + " should contain " + testCase.expectedResultContents,
+ result.containsAll(testCase.expectedResultContents));
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));
@@ -374,15 +374,19 @@
Assert.assertEquals(testCase.expectedInterestCount, countActionsByType(result, Action.APPLY_INTEREST));
Assert.assertEquals(1, countActionsByType(result, Action.APPROVE));
Assert.assertEquals(1, countActionsByType(result, Action.CLOSE));
- result.forEach(x -> Assert.assertNotNull(x.actionPeriod));
- result.forEach(x -> Assert.assertNotNull(x.repaymentPeriod));
+ result.stream().filter(scheduledAction -> !ScheduledActionHelpers.actionHasNoActionPeriod(scheduledAction.action))
+ .forEach(scheduledAction -> {
+ Assert.assertNotNull("The action period of " + scheduledAction.toString() + " should not be null.",
+ scheduledAction.actionPeriod);
+ Assert.assertNotNull("The repayment period of " + scheduledAction.toString() + " should not be null.",
+ scheduledAction.repaymentPeriod);
+ });
Assert.assertTrue(noDuplicatesInResult(result));
Assert.assertTrue(maximumOneInterestPerDay(result));
}
private long countActionsByType(final List<ScheduledAction> scheduledActions, final Action actionToCount) {
- return scheduledActions.stream().filter(x -> x.action == actionToCount)
- .collect(Collectors.counting());
+ return scheduledActions.stream().filter(x -> x.action == actionToCount).count();
}
private boolean maximumOneInterestPerDay(final List<ScheduledAction> result) {