+ * Indicates that the action should be deferred to the next workday. + *
+ *+ * Example 1: + * MO TU WE TH FR SA SO + * H WE WE -> Holiday/WeekEnd + * X -> Due date of action + * X' -> Deferred, effective due date of action + *+ *
+ * Example 2: + * TU WE TH FR SA SO MO + * H WE WE -> Holiday/WeekEnd + * X -> Due date of action + * X' -> Deferred, effective due date of action + *+ * + */ NEXT_WORKDAY, - /** Indicates that the action should be dated back to the previous day */ + /** + *
+ * Indicates that the action should be made earlier at the previous day + *
+ *+ * Example 1: + * MO TU WE TH FR SA SO + * H WE WE -> Holiday/WeekEnd + * X -> Due date of action + * X' -> Earlier, effective due date of action + *+ *
+ * Example 2: + * MO TU WE TH FR SA SO + * H WE WE -> Holiday/WeekEnd + * X -> Due date of action + * X' -> Earlier, effective due date of action + *+ */ PREVIOUS_WORKDAY; /** diff --git a/src/main/java/de/financer/model/RecurringTransaction.java b/src/main/java/de/financer/model/RecurringTransaction.java index 97954cc..1a9d69e 100644 --- a/src/main/java/de/financer/model/RecurringTransaction.java +++ b/src/main/java/de/financer/model/RecurringTransaction.java @@ -1,7 +1,7 @@ package de.financer.model; import javax.persistence.*; -import java.util.Date; +import java.time.LocalDate; @Entity public class RecurringTransaction { @@ -16,10 +16,8 @@ public class RecurringTransaction { private Long amount; @Enumerated(EnumType.STRING) private IntervalType intervalType; - @Temporal(TemporalType.DATE) - private Date firstOccurrence; - @Temporal(TemporalType.DATE) - private Date lastOccurrence; + private LocalDate firstOccurrence; + private LocalDate lastOccurrence; @Enumerated(EnumType.STRING) private HolidayWeekendType holidayWeekendType; @@ -67,19 +65,19 @@ public class RecurringTransaction { this.holidayWeekendType = holidayWeekendType; } - public Date getLastOccurrence() { + public LocalDate getLastOccurrence() { return lastOccurrence; } - public void setLastOccurrence(Date lastOccurrence) { + public void setLastOccurrence(LocalDate lastOccurrence) { this.lastOccurrence = lastOccurrence; } - public Date getFirstOccurrence() { + public LocalDate getFirstOccurrence() { return firstOccurrence; } - public void setFirstOccurrence(Date firstOccurrence) { + public void setFirstOccurrence(LocalDate firstOccurrence) { this.firstOccurrence = firstOccurrence; } diff --git a/src/main/java/de/financer/model/Transaction.java b/src/main/java/de/financer/model/Transaction.java index b42ca7d..0b33788 100644 --- a/src/main/java/de/financer/model/Transaction.java +++ b/src/main/java/de/financer/model/Transaction.java @@ -1,7 +1,7 @@ package de.financer.model; import javax.persistence.*; -import java.util.Date; +import java.time.LocalDate; @Entity @Table(name = "\"transaction\"") @@ -13,9 +13,8 @@ public class Transaction { private Account fromAccount; @OneToOne(fetch = FetchType.EAGER) private Account toAccount; - @Temporal(TemporalType.DATE) @Column(name = "\"date\"") - private Date date; + private LocalDate date; private String description; private Long amount; @ManyToOne(fetch = FetchType.EAGER) @@ -41,11 +40,11 @@ public class Transaction { this.toAccount = toAccount; } - public Date getDate() { + public LocalDate getDate() { return date; } - public void setDate(Date date) { + public void setDate(LocalDate date) { this.date = date; } diff --git a/src/main/java/de/financer/model/package-info.java b/src/main/java/de/financer/model/package-info.java new file mode 100644 index 0000000..421aaf9 --- /dev/null +++ b/src/main/java/de/financer/model/package-info.java @@ -0,0 +1,10 @@ +/** + *
+ * This package contains the main model for the financer application. + * In the DDD (Domain Driven Design) sense the models are anemic + * as they contain no logic themselves but act as mere POJOs with additional + * Hibernate annotations. The (business) logic is located in the services of the + * {@link de.financer.service} package. + *
+ */ +package de.financer.model; \ No newline at end of file diff --git a/src/main/java/de/financer/service/RecurringTransactionService.java b/src/main/java/de/financer/service/RecurringTransactionService.java new file mode 100644 index 0000000..f03cf76 --- /dev/null +++ b/src/main/java/de/financer/service/RecurringTransactionService.java @@ -0,0 +1,163 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.RecurringTransactionRepository; +import de.financer.model.Account; +import de.financer.model.HolidayWeekendType; +import de.financer.model.RecurringTransaction; +import org.apache.commons.collections4.IterableUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.stream.Collectors; + +@Service +public class RecurringTransactionService { + + @Autowired + private RecurringTransactionRepository recurringTransactionRepository; + + @Autowired + private AccountService accountService; + + @Autowired + private RuleService ruleService; + + public Iterabletrue if the recurring transaction is due today, false otherwise
+ */
+ private boolean checkRecurringTransactionDueToday(RecurringTransaction recurringTransaction, LocalDate now) {
+ final boolean holiday = this.ruleService.isHoliday(now);
+
+ final boolean dueToday = recurringTransaction.getFirstOccurrence()
+ // This calculates all dates between the first occurrence of the
+ // recurring transaction and tomorrow for the interval specified
+ // by the recurring transaction. We need to use tomorrow as
+ // upper bound of the interval because the upper bound is exclusive
+ // in the datesUntil method.
+ .datesUntil(now.plusDays(1), this.ruleService
+ .getPeriodForInterval(recurringTransaction
+ .getIntervalType()))
+ // Then we check whether today is a date in the calculated range.
+ // If so the recurring transaction is due today
+ .anyMatch((d) -> d.equals(now));
+ final boolean weekend = this.ruleService.isWeekend(now);
+ boolean defer = false;
+
+
+ if (holiday || weekend) {
+ defer = recurringTransaction.getHolidayWeekendType() == HolidayWeekendType.NEXT_WORKDAY;
+ }
+
+ return !defer && dueToday;
+ }
+
+ /**
+ * This method checks whether the given {@link RecurringTransaction} was actually due in the close past
+ * but has been deferred to maybe today because the actual due day has been a holiday or weekend day and the
+ * {@link RecurringTransaction#getHolidayWeekendType() holiday weekend type} was
+ * {@link HolidayWeekendType#NEXT_WORKDAY}. Note that the recurring transaction may get deferred again if today
+ * again is a holiday or a weekend day.
+ * The period this method considers starts with today and ends with the last workday (no
+ * {@link RuleService#isHoliday(LocalDate) holiday}, not a {@link RuleService#isWeekend(LocalDate) weekend day})
+ * whereas the end is exclusive, because if the recurring transaction would have been due at the last workday day
+ * it wouldn't has been deferred.
+ *
+ * @param recurringTransaction to check whether it is due today
+ * @param now today's date
+ * @return true if the recurring transaction is due today, false otherwise
+ */
+ private boolean checkRecurringTransactionDuePast(RecurringTransaction recurringTransaction, LocalDate now) {
+ boolean weekend;
+ boolean holiday;
+ LocalDate yesterday = now;
+ boolean due = false;
+
+ // Go back in time until we hit the first non-holiday, non-weekend day
+ // and check for every day in between if the given recurring transaction was due on this day
+ do {
+ yesterday = yesterday.minusDays(1);
+ holiday = this.ruleService.isHoliday(yesterday);
+ weekend = this.ruleService.isWeekend(yesterday);
+
+ if (holiday || weekend) {
+ // Lambdas require final local variables
+ final LocalDate finalYesterday = yesterday;
+
+ // For an explanation of the expression see the ...DueToday method
+ due = recurringTransaction.getFirstOccurrence()
+ .datesUntil(yesterday.plusDays(1), this.ruleService
+ .getPeriodForInterval(recurringTransaction
+ .getIntervalType()))
+ .anyMatch((d) -> d.equals(finalYesterday));
+
+ if (due) {
+ break;
+ }
+ }
+ }
+ while (holiday || weekend);
+
+ return due;
+ }
+
+ public ResponseReason createRecurringTransaction(String fromAccountKey, String toAccountKey, Long amount,
+ String description, String holidayWeekendType,
+ String intervalType, String firstOccurrence,
+ String lastOccurrence) {
+ return null;
+ }
+}
diff --git a/src/main/java/de/financer/service/RuleService.java b/src/main/java/de/financer/service/RuleService.java
index 220fe79..7dee141 100644
--- a/src/main/java/de/financer/service/RuleService.java
+++ b/src/main/java/de/financer/service/RuleService.java
@@ -1,27 +1,51 @@
package de.financer.service;
+import de.financer.config.FinancerConfig;
import de.financer.model.Account;
import de.financer.model.AccountType;
+import de.financer.model.IntervalType;
+import de.jollyday.HolidayManager;
+import de.jollyday.ManagerParameters;
import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.Period;
import java.util.*;
import static de.financer.model.AccountType.*;
/**
- * This service encapsulates methods that form business logic rules.
+ * This service encapsulates methods that form basic logic rules.
* While most of the logic could be placed elsewhere this service provides
- * centralized access to these rules.
+ * centralized access to these rules. Placing them in here also enables easy
+ * unit testing.
*/
@Service
public class RuleService implements InitializingBean {
+ @Autowired
+ private FinancerConfig financerConfig;
+
private Map* The multiplier controls whether the current amount of the given from account is increased or * decreased depending on the {@link AccountType} of the given account. * @@ -55,17 +79,13 @@ public class RuleService implements InitializingBean { if (INCOME.equals(accountType)) { return 1L; - } - else if (BANK.equals(accountType)) { + } else if (BANK.equals(accountType)) { return -1L; - } - else if (CASH.equals(accountType)) { + } else if (CASH.equals(accountType)) { return -1L; - } - else if (LIABILITY.equals(accountType)) { + } else if (LIABILITY.equals(accountType)) { return 1L; - } - else if (START.equals(accountType)) { + } else if (START.equals(accountType)) { return 1L; } @@ -74,7 +94,7 @@ public class RuleService implements InitializingBean { /** * This method returns the multiplier for the given to account. - * + *
* The multiplier controls whether the current amount of the given to account is increased or
* decreased depending on the {@link AccountType} of the given account.
*
@@ -89,14 +109,11 @@ public class RuleService implements InitializingBean {
if (BANK.equals(accountType)) {
return 1L;
- }
- else if (CASH.equals(accountType)) {
+ } else if (CASH.equals(accountType)) {
return 1L;
- }
- else if (LIABILITY.equals(accountType)) {
+ } else if (LIABILITY.equals(accountType)) {
return -1L;
- }
- else if (EXPENSE.equals(accountType)) {
+ } else if (EXPENSE.equals(accountType)) {
return 1L;
}
@@ -109,10 +126,43 @@ public class RuleService implements InitializingBean {
* account does not make sense and is declared as invalid.
*
* @param fromAccount the account to subtract the money from
- * @param toAccount the account to add the money to
- * @return
+ * This package contains the actual business logic services of the financer application.
+ * They get called by the {@link de.financer.controller controller} layer. Also they call each other,
+ * e.g. the {@link de.financer.service.TransactionService} internally uses the {@link de.financer.service.RuleService}.
+ * Data access is done via the {@link de.financer.dba DBA} layer.
+ * true if the from->to relationship of the given accounts is valid, false otherwise
+ * @param toAccount the account to add the money to
+ * @return true if the from->to relationship of the given accounts is valid, false otherwise
*/
public boolean isValidBooking(Account fromAccount, Account toAccount) {
return this.bookingRules.get(fromAccount.getType()).contains(toAccount.getType());
}
+
+ /**
+ * This method gets the {@link Period} for the given {@link IntervalType},
+ * e.g. a period of three months for {@link IntervalType#QUARTERLY}.
+ *
+ * @param intervalType to get the period for
+ * @return the period matching the interval type
+ */
+ public Period getPeriodForInterval(IntervalType intervalType) {
+ return this.intervalPeriods.get(intervalType);
+ }
+
+ /**
+ * This method checks whether the given date is a holiday in the configured country and state.
+ *
+ * @param now the date to check
+ * @return true if the given date is a holiday, false otherwise
+ */
+ public boolean isHoliday(LocalDate now) {
+ return HolidayManager.getInstance(ManagerParameters.create(this.financerConfig.getHolidayCalendar()))
+ .isHoliday(now, this.financerConfig.getState());
+ }
+
+ /**
+ * This method checks whether the given date is a weekend day, i.e. whether it's a
+ * {@link DayOfWeek#SATURDAY} or {@link DayOfWeek#SUNDAY}.
+ *
+ * @param now the date to check
+ * @return true if the given date is a weekend day, false otherwise
+ */
+ public boolean isWeekend(LocalDate now) {
+ return EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(now.getDayOfWeek());
+ }
}
diff --git a/src/main/java/de/financer/service/TransactionService.java b/src/main/java/de/financer/service/TransactionService.java
index 61554e1..7e3cdca 100644
--- a/src/main/java/de/financer/service/TransactionService.java
+++ b/src/main/java/de/financer/service/TransactionService.java
@@ -9,8 +9,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.Collections;
@Service
@@ -36,7 +37,7 @@ public class TransactionService {
/**
* @param accountKey the key of the account to get the transactions for
* @return all transactions for the given account, for all time. Returns an empty list if the given key does not
- * match any account.
+ * match any account.
*/
public Iterablenull if all parameters are valid
*/
private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, String date) {
@@ -124,23 +125,17 @@ public class TransactionService {
if (fromAccount == null && toAccount == null) {
response = ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND;
- }
- else if (toAccount == null) {
+ } else if (toAccount == null) {
response = ResponseReason.TO_ACCOUNT_NOT_FOUND;
- }
- else if (fromAccount == null) {
+ } else if (fromAccount == null) {
response = ResponseReason.FROM_ACCOUNT_NOT_FOUND;
- }
- else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) {
+ } else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) {
response = ResponseReason.INVALID_BOOKING_ACCOUNTS;
- }
- else if (amount == null) {
+ } else if (amount == null) {
response = ResponseReason.MISSING_AMOUNT;
- }
- else if (amount == 0l) {
+ } else if (amount == 0l) {
response = ResponseReason.AMOUNT_ZERO;
- }
- else if (date == null) {
+ } else if (date == null) {
response = ResponseReason.MISSING_DATE;
}
diff --git a/src/main/java/de/financer/service/package-info.java b/src/main/java/de/financer/service/package-info.java
new file mode 100644
index 0000000..d440c80
--- /dev/null
+++ b/src/main/java/de/financer/service/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * >(){});
+ final List
>() {});
- Assert.assertEquals(4, allAccounts.size());
+ Assert.assertEquals(5, allAccounts.size());
}
}
diff --git a/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java b/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java
new file mode 100644
index 0000000..a978bf5
--- /dev/null
+++ b/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java
@@ -0,0 +1,134 @@
+package de.financer.service;
+
+import de.financer.dba.RecurringTransactionRepository;
+import de.financer.model.HolidayWeekendType;
+import de.financer.model.IntervalType;
+import de.financer.model.RecurringTransaction;
+import org.apache.commons.collections4.IterableUtils;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.util.Collections;
+
+/**
+ * This class contains tests for the {@link RecurringTransactionService}, specifically for
+ * {@link RecurringTransaction}s that have {@link IntervalType#DAILY} and {@link HolidayWeekendType#NEXT_WORKDAY}.
+ * Due to these restrictions this class does not contain any tests for recurring transactions due in the close past
+ * that have been deferred, because recurring transactions with interval type daily get executed on the next workday
+ * anyway, regardless whether they have been deferred. This means that some executions of a recurring transaction with
+ * daily/next workday get ignored if they are on a holiday or a weekend day - they do not get executed multiple
+ * times on the next workday. While this is somehow unfortunate it is not in the current requirements and
+ * therefore left out for the sake of simplicity. If such a behavior is required daily/same day should do the trick,
+ * even though with slightly different semantics (execution even on holidays or weekends).
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest {
+ @InjectMocks
+ private RecurringTransactionService classUnderTest;
+
+ @Mock
+ private RecurringTransactionRepository recurringTransactionRepository;
+
+ @Mock
+ private RuleService ruleService;
+
+ @Before
+ public void setUp() {
+ Mockito.when(this.ruleService.getPeriodForInterval(IntervalType.DAILY)).thenReturn(Period.ofDays(1));
+ }
+
+ /**
+ * This method tests whether a recurring transaction with firstOccurrence = three days ago, intervalType = daily and
+ * holidayWeekendType = next_workday is due on a non-holiday, non-weekend day
+ */
+ @Test
+ public void test_getAllDueToday_dueToday() {
+ // Arrange
+ // Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false)
+ Mockito.when(this.recurringTransactionRepository.findAll()).thenReturn(Collections.singletonList(createRecurringTransaction(-3)));
+ final LocalDate now = LocalDate.now();
+
+ // Act
+ final Iterable