%20 with a space.
+ *
+ * @param toDecode the string to decode
+ * @return the decoded string in UTF-8 or, if UTF-8 is not available for whatever reason, the encoded string
+ */
+ public static final String urlDecode(String toDecode) {
+ try {
+ return URLDecoder.decode(toDecode, StandardCharsets.UTF_8.name());
+ }
+ catch (UnsupportedEncodingException e) {
+ return toDecode;
+ }
+ }
+}
diff --git a/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java b/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java
new file mode 100644
index 0000000..91738b7
--- /dev/null
+++ b/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java
@@ -0,0 +1,119 @@
+package de.financer.controller;
+
+import de.financer.ResponseReason;
+import de.financer.model.RecurringTransaction;
+import de.financer.service.RecurringTransactionService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+@RestController
+@RequestMapping("recurringTransactions")
+public class RecurringTransactionController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RecurringTransactionController.class);
+
+ @Autowired
+ private RecurringTransactionService recurringTransactionService;
+
+ @RequestMapping("getAll")
+ public Iterable+ * 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 preponed to 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; + + /** + * This method validates whether the given string represents a valid holiday weekend type. + * + * @param type to check + * @return whether the given type represents a valid holiday weekend type + */ + public static boolean isValidType(String type) { + return Arrays.stream(HolidayWeekendType.values()).anyMatch((holidayWeekendType) -> holidayWeekendType.name().equals(type)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/IntervalType.java b/financer-server/src/main/java/de/financer/model/IntervalType.java new file mode 100644 index 0000000..952f9b8 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/IntervalType.java @@ -0,0 +1,30 @@ +package de.financer.model; + +import java.util.Arrays; + +public enum IntervalType { + /** Indicates that an action should be executed every day */ + DAILY, + + /** Indicates that an action should be executed once a week */ + WEEKLY, + + /** Indicates that an action should be executed once a month */ + MONTHLY, + + /** Indicates that an action should be executed once a quarter */ + QUARTERLY, + + /** Indicates that an action should be executed once a year */ + YEARLY; + + /** + * This method validates whether the given string represents a valid interval type. + * + * @param type to check + * @return whether the given type represents a valid interval type + */ + public static boolean isValidType(String type) { + return Arrays.stream(IntervalType.values()).anyMatch((intervalType) -> intervalType.name().equals(type)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/RecurringTransaction.java b/financer-server/src/main/java/de/financer/model/RecurringTransaction.java new file mode 100644 index 0000000..135fabe --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/RecurringTransaction.java @@ -0,0 +1,109 @@ +package de.financer.model; + +import javax.persistence.*; +import java.time.LocalDate; + +@Entity +public class RecurringTransaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private Account fromAccount; + @OneToOne(fetch = FetchType.EAGER) + private Account toAccount; + private String description; + private Long amount; + @Enumerated(EnumType.STRING) + private IntervalType intervalType; + private LocalDate firstOccurrence; + private LocalDate lastOccurrence; + @Enumerated(EnumType.STRING) + private HolidayWeekendType holidayWeekendType; + private boolean deleted; + private boolean remind; + + public Long getId() { + return id; + } + + public Account getFromAccount() { + return fromAccount; + } + + public void setFromAccount(Account fromAccount) { + this.fromAccount = fromAccount; + } + + public Account getToAccount() { + return toAccount; + } + + public void setToAccount(Account toAccount) { + this.toAccount = toAccount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public HolidayWeekendType getHolidayWeekendType() { + return holidayWeekendType; + } + + public void setHolidayWeekendType(HolidayWeekendType holidayWeekendType) { + this.holidayWeekendType = holidayWeekendType; + } + + public LocalDate getLastOccurrence() { + return lastOccurrence; + } + + public void setLastOccurrence(LocalDate lastOccurrence) { + this.lastOccurrence = lastOccurrence; + } + + public LocalDate getFirstOccurrence() { + return firstOccurrence; + } + + public void setFirstOccurrence(LocalDate firstOccurrence) { + this.firstOccurrence = firstOccurrence; + } + + public IntervalType getIntervalType() { + return intervalType; + } + + public void setIntervalType(IntervalType intervalType) { + this.intervalType = intervalType; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + + public boolean isRemind() { + return remind; + } + + public void setRemind(boolean remind) { + this.remind = remind; + } +} diff --git a/financer-server/src/main/java/de/financer/model/Transaction.java b/financer-server/src/main/java/de/financer/model/Transaction.java new file mode 100644 index 0000000..0b33788 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/Transaction.java @@ -0,0 +1,74 @@ +package de.financer.model; + +import javax.persistence.*; +import java.time.LocalDate; + +@Entity +@Table(name = "\"transaction\"") +public class Transaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private Account fromAccount; + @OneToOne(fetch = FetchType.EAGER) + private Account toAccount; + @Column(name = "\"date\"") + private LocalDate date; + private String description; + private Long amount; + @ManyToOne(fetch = FetchType.EAGER) + private RecurringTransaction recurringTransaction; + + public Long getId() { + return id; + } + + public Account getFromAccount() { + return fromAccount; + } + + public void setFromAccount(Account fromAccount) { + this.fromAccount = fromAccount; + } + + public Account getToAccount() { + return toAccount; + } + + public void setToAccount(Account toAccount) { + this.toAccount = toAccount; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public RecurringTransaction getRecurringTransaction() { + return recurringTransaction; + } + + public void setRecurringTransaction(RecurringTransaction recurringTransaction) { + this.recurringTransaction = recurringTransaction; + } +} diff --git a/financer-server/src/main/java/de/financer/model/package-info.java b/financer-server/src/main/java/de/financer/model/package-info.java new file mode 100644 index 0000000..421aaf9 --- /dev/null +++ b/financer-server/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/financer-server/src/main/java/de/financer/service/AccountGroupService.java b/financer-server/src/main/java/de/financer/service/AccountGroupService.java new file mode 100644 index 0000000..1b41de9 --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/AccountGroupService.java @@ -0,0 +1,69 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountGroupRepository; +import de.financer.model.AccountGroup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountGroupService { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountGroupService.class); + + @Autowired + private AccountGroupRepository accountGroupRepository; + + /** + * @return all existing account groups + */ + public Iterablenull if no account group with the given name can be found
+ */
+ public AccountGroup getAccountGroupByName(String name) {
+ return this.accountGroupRepository.findByName(name);
+ }
+
+ /**
+ * This method creates a new account group with the given name.
+ *
+ * @param name the name of the new account group
+ * @return {@link ResponseReason#DUPLICATE_ACCOUNT_GROUP_NAME} if an account group with the given name already exists,
+ * {@link ResponseReason#UNKNOWN_ERROR} if an unknown error occurs,
+ * {@link ResponseReason#OK} if the operation completed successfully.
+ * Never returns null.
+ */
+ @Transactional(propagation = Propagation.SUPPORTS)
+ public ResponseReason createAccountGroup(String name) {
+ final AccountGroup accountGroup = new AccountGroup();
+
+ accountGroup.setName(name);
+
+ try {
+ this.accountGroupRepository.save(accountGroup);
+ }
+ catch (DataIntegrityViolationException dive) {
+ LOGGER.error(String.format("Duplicate account group name! %s", name), dive);
+
+ return ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME;
+ }
+ catch (Exception e) {
+ LOGGER.error(String.format("Could not save account group %s", name), e);
+
+ return ResponseReason.UNKNOWN_ERROR;
+ }
+
+ return ResponseReason.OK;
+ }
+}
diff --git a/financer-server/src/main/java/de/financer/service/AccountService.java b/financer-server/src/main/java/de/financer/service/AccountService.java
new file mode 100644
index 0000000..9e4559a
--- /dev/null
+++ b/financer-server/src/main/java/de/financer/service/AccountService.java
@@ -0,0 +1,145 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.dba.AccountRepository;
+import de.financer.model.Account;
+import de.financer.model.AccountGroup;
+import de.financer.model.AccountStatus;
+import de.financer.model.AccountType;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+@Service
+public class AccountService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(AccountService.class);
+
+ @Autowired
+ private AccountRepository accountRepository;
+ @Autowired
+ private AccountGroupService accountGroupService;
+
+ /**
+ * This method returns the account identified by the given key.
+ *
+ * @param key the key to get the account for
+ * @return the account or null if no account with the given key can be found
+ */
+ public Account getAccountByKey(String key) {
+ return this.accountRepository.findByKey(key);
+ }
+
+ /**
+ * @return all existing accounts, regardless of their type or status. This explicitly covers accounts in {@link AccountStatus#CLOSED CLOSED} as well.
+ */
+ public Iterable0.
+ *
+ * @param key the key of the new account
+ * @param type the type of the new account. Must be one of {@link AccountType}.
+ * @param accountGroupName the name of the account group to use, can be null
+ * @return {@link ResponseReason#INVALID_ACCOUNT_TYPE} if the given type is not a valid {@link AccountType},
+ * {@link ResponseReason#UNKNOWN_ERROR} if an unexpected error occurs,
+ * {@link ResponseReason#OK} if the operation completed successfully,
+ * {@link ResponseReason#DUPLICATE_ACCOUNT_KEY} if an account with the given key
+ * already exists and {@link ResponseReason#ACCOUNT_GROUP_NOT_FOUND} if the optional parameter
+ * accountGroupName does not identify a valid account group. Never returns null.
+ */
+ @Transactional(propagation = Propagation.SUPPORTS)
+ public ResponseReason createAccount(String key, String type, String accountGroupName) {
+ if (!AccountType.isValidType(type)) {
+ return ResponseReason.INVALID_ACCOUNT_TYPE;
+ }
+
+ final Account account = new Account();
+
+ if (StringUtils.isNotEmpty(accountGroupName)) {
+ final AccountGroup accountGroup = this.accountGroupService.getAccountGroupByName(accountGroupName);
+
+ if (accountGroup == null) {
+ return ResponseReason.ACCOUNT_GROUP_NOT_FOUND; // early return
+ }
+
+ account.setAccountGroup(accountGroup);
+ }
+
+ account.setKey(key);
+ account.setType(AccountType.valueOf(type));
+ // If we create an account it's implicitly open
+ account.setStatus(AccountStatus.OPEN);
+ // and has a current balance of 0
+ account.setCurrentBalance(Long.valueOf(0L));
+
+ try {
+ this.accountRepository.save(account);
+ }
+ catch (DataIntegrityViolationException dive) {
+ LOGGER.error(String.format("Duplicate key! %s|%s|%s", key, type, accountGroupName), dive);
+
+ return ResponseReason.DUPLICATE_ACCOUNT_KEY;
+ }
+ catch (Exception e) {
+ LOGGER.error(String.format("Could not save account %s|%s|%s", key, type, accountGroupName), e);
+
+ return ResponseReason.UNKNOWN_ERROR;
+ }
+
+ return ResponseReason.OK;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason closeAccount(String key) {
+ return setAccountStatus(key, AccountStatus.CLOSED);
+ }
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason openAccount(String key) {
+ return setAccountStatus(key, AccountStatus.OPEN);
+ }
+
+ // Visible for unit tests
+ /* package */ ResponseReason setAccountStatus(String key, AccountStatus accountStatus) {
+ final Account account = this.accountRepository.findByKey(key);
+
+ if (account == null) {
+ return ResponseReason.ACCOUNT_NOT_FOUND;
+ }
+
+ account.setStatus(accountStatus);
+
+ try {
+ this.accountRepository.save(account);
+ }
+ catch (Exception e) {
+ LOGGER.error(String.format("Could not update account status %s|%s", key, accountStatus.name()), e);
+
+ return ResponseReason.UNKNOWN_ERROR;
+ }
+
+ return ResponseReason.OK;
+ }
+}
diff --git a/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java
new file mode 100644
index 0000000..a9bb2b3
--- /dev/null
+++ b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java
@@ -0,0 +1,498 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.config.FinancerConfig;
+import de.financer.dba.RecurringTransactionRepository;
+import de.financer.model.Account;
+import de.financer.model.HolidayWeekendType;
+import de.financer.model.IntervalType;
+import de.financer.model.RecurringTransaction;
+import org.apache.commons.collections4.IterableUtils;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+public class RecurringTransactionService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(RecurringTransactionService.class);
+
+ @Autowired
+ private RecurringTransactionRepository recurringTransactionRepository;
+
+ @Autowired
+ private AccountService accountService;
+
+ @Autowired
+ private RuleService ruleService;
+
+ @Autowired
+ private FinancerConfig financerConfig;
+
+ @Autowired
+ private TransactionService transactionService;
+
+ public Iterabletrue if the recurring transaction is due today, false otherwise
+ */
+ private boolean checkRecurringTransactionDueToday(RecurringTransaction recurringTransaction, LocalDate now) {
+ // If a recurring transactions first occurrence is in the future it can never be relevant for this
+ // method. This case will be handled in the checkRecurringTransactionDueFuture method if the recurring
+ // transaction also has HolidayWeekendType#PREVIOUS_WORKDAY.
+ // If this check is not done the datesUntil(...) call will fail as it expects that the callees date is lower
+ // or equal the first parameter which is not the case for the following example:
+ // callee.firstOccurrence = 2019-05-27
+ // now = 2019-05-14
+ // now.plusDays(1) = 2019-05-15
+ // => IllegalArgumentException: 2019-05-15 < 2019-05-27
+ if (recurringTransaction.getFirstOccurrence().isAfter(now)) {
+ LOGGER.debug(String.format("Recurring transaction %s has its first occurrence in the future and thus " +
+ "cannot be due today",
+ ReflectionToStringBuilder.toString(recurringTransaction)));
+
+ return false; // early return
+ }
+
+ 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
+ || recurringTransaction.getHolidayWeekendType() == HolidayWeekendType.PREVIOUS_WORKDAY;
+ }
+
+ LOGGER.debug(String.format("Recurring transaction %s due today? %s (defer=%s, dueToday=%s)",
+ ReflectionToStringBuilder.toString(recurringTransaction), (!defer && dueToday), defer, dueToday));
+
+ 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) {
+ // Recurring transactions with holiday weekend type SAME_DAY or PREVIOUS_WORKDAY can't be due in the past
+ if (!HolidayWeekendType.NEXT_WORKDAY.equals(recurringTransaction.getHolidayWeekendType())) {
+ LOGGER.debug(String.format("Recurring transaction %s has HWT %s and thus cannot be due in the past",
+ ReflectionToStringBuilder.toString(recurringTransaction),
+ recurringTransaction.getHolidayWeekendType()));
+
+ return false; // early return
+ }
+
+ // If a recurring transactions first occurrence is in the future it can never be relevant for this
+ // method, as this method handles recurring transactions due in the past.
+ if (recurringTransaction.getFirstOccurrence().isAfter(now)) {
+ LOGGER.debug(String.format("Recurring transaction %s has its first occurrence in the future and thus " +
+ "cannot be due in the past",
+ ReflectionToStringBuilder.toString(recurringTransaction)));
+
+ return false; // early return
+ }
+
+ // If today is a weekend day or holiday the recurring transaction cannot be due today, because the
+ // holiday weekend type says NEXT_WORKDAY.
+ if (this.ruleService.isHoliday(now) || this.ruleService.isWeekend(now)) {
+ LOGGER.debug(String.format("Recurring transaction %s has HWT %s and today is either a holiday or weekend," +
+ " thus it cannot be due in the past",
+ ReflectionToStringBuilder.toString(recurringTransaction),
+ recurringTransaction.getHolidayWeekendType()));
+
+ return false; // early return
+ }
+
+ 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);
+
+ LOGGER.debug(String.format("Recurring transaction %s is due in the past? %s",
+ ReflectionToStringBuilder.toString(recurringTransaction), due));
+
+ return due;
+ }
+
+ /**
+ * This method checks whether the given {@link RecurringTransaction} will actually be due in the close future will
+ * be preponed to maybe today because the actual due day will be a holiday or weekend day and the {@link
+ * RecurringTransaction#getHolidayWeekendType() holiday weekend type} is {@link
+ * HolidayWeekendType#PREVIOUS_WORKDAY}. The period this method considers starts with today and ends with the next
+ * 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 will due at the next workday day
+ * it does not need to be preponed.
+ *
+ * @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 checkRecurringTransactionDueFuture(RecurringTransaction recurringTransaction, LocalDate now) {
+ // Recurring transactions with holiday weekend type SAME_DAY or PREVIOUS_WORKDAY can't be due in the future
+ if (!HolidayWeekendType.PREVIOUS_WORKDAY.equals(recurringTransaction.getHolidayWeekendType())) {
+ LOGGER.debug(String.format("Recurring transaction %s has HWT %s and thus cannot be due in the future",
+ ReflectionToStringBuilder.toString(recurringTransaction),
+ recurringTransaction.getHolidayWeekendType()));
+
+ return false; // early return
+ }
+
+ boolean weekend;
+ boolean holiday;
+ LocalDate tomorrow = now;
+ boolean due = false;
+
+ // Go forth in time until we hit the first non-holiday, non-weekend day
+ // and check for every day in between if the given recurring transaction will be due on this day
+ do {
+ tomorrow = tomorrow.plusDays(1);
+ holiday = this.ruleService.isHoliday(tomorrow);
+ weekend = this.ruleService.isWeekend(tomorrow);
+
+ if (holiday || weekend) {
+ // Lambdas require final local variables
+ final LocalDate finalTomorrow = tomorrow;
+
+ // For an explanation of the expression see the ...DueToday method
+ due = recurringTransaction.getFirstOccurrence()
+ .datesUntil(tomorrow.plusDays(1), this.ruleService
+ .getPeriodForInterval(recurringTransaction
+ .getIntervalType()))
+ .anyMatch((d) -> d.equals(finalTomorrow));
+
+ if (due) {
+ break;
+ }
+ }
+ }
+ while (holiday || weekend);
+
+ LOGGER.debug(String.format("Recurring transaction %s is due in the future? %s",
+ ReflectionToStringBuilder.toString(recurringTransaction), due));
+
+ return due;
+ }
+
+
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason createRecurringTransaction(String fromAccountKey, String toAccountKey, Long amount,
+ String description, String holidayWeekendType,
+ String intervalType, String firstOccurrence,
+ String lastOccurrence, Boolean remind
+ ) {
+ final Account fromAccount = this.accountService.getAccountByKey(fromAccountKey);
+ final Account toAccount = this.accountService.getAccountByKey(toAccountKey);
+ ResponseReason response = validateParameters(fromAccount, toAccount, amount, holidayWeekendType, intervalType,
+ firstOccurrence, lastOccurrence); // no validation of 'remind' as it's completely optional
+
+ // If we detected an issue with the given parameters return the first error found to the caller
+ if (response != null) {
+ return response; // early return
+ }
+
+ try {
+ final RecurringTransaction transaction = buildRecurringTransaction(fromAccount, toAccount, amount,
+ description, holidayWeekendType, intervalType, firstOccurrence, lastOccurrence, remind);
+
+ this.recurringTransactionRepository.save(transaction);
+
+ response = ResponseReason.OK;
+ } catch (Exception e) {
+ LOGGER.error("Could not create recurring transaction!", e);
+
+ response = ResponseReason.UNKNOWN_ERROR;
+ }
+
+ return response;
+ }
+
+ /**
+ * This method builds the actual recurring transaction object with the given values.
+ *
+ * @param fromAccount the from account
+ * @param toAccount the to account
+ * @param amount the transaction amount
+ * @param holidayWeekendType the holiday weekend type
+ * @param intervalType the interval type
+ * @param firstOccurrence the first occurrence
+ * @param lastOccurrence the last occurrence, may be null
+ * @param remind the remind flag
+ *
+ * @return the build {@link RecurringTransaction} instance
+ */
+ private RecurringTransaction buildRecurringTransaction(Account fromAccount, Account toAccount, Long amount,
+ String description, String holidayWeekendType,
+ String intervalType, String firstOccurrence,
+ String lastOccurrence, Boolean remind
+ ) {
+ final RecurringTransaction recurringTransaction = new RecurringTransaction();
+
+ recurringTransaction.setFromAccount(fromAccount);
+ recurringTransaction.setToAccount(toAccount);
+ recurringTransaction.setAmount(amount);
+ recurringTransaction.setDescription(description);
+ recurringTransaction.setHolidayWeekendType(HolidayWeekendType.valueOf(holidayWeekendType));
+ recurringTransaction.setIntervalType(IntervalType.valueOf(intervalType));
+ recurringTransaction.setFirstOccurrence(LocalDate
+ .parse(firstOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())));
+ // See 'resources/database/postgres/readme_V1_0_0__init.txt'
+ recurringTransaction.setDeleted(false);
+ recurringTransaction.setRemind(BooleanUtils.toBooleanDefaultIfNull(remind, true));
+
+ // lastOccurrence is optional
+ if (StringUtils.isNotEmpty(lastOccurrence)) {
+ recurringTransaction.setLastOccurrence(LocalDate
+ .parse(lastOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())));
+ }
+
+ return recurringTransaction;
+ }
+
+ /**
+ * This method checks whether the parameters for creating a transaction are valid.
+ *
+ * @param fromAccount the from account
+ * @param toAccount the to account
+ * @param amount the transaction amount
+ * @param holidayWeekendType the holiday weekend type
+ * @param intervalType the interval type
+ * @param firstOccurrence the first occurrence
+ * @param lastOccurrence the last occurrence, may be null
+ *
+ * @return the first error found or null if all parameters are valid
+ */
+ private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount,
+ String holidayWeekendType, String intervalType, String firstOccurrence,
+ String lastOccurrence
+ ) {
+ ResponseReason response = null;
+
+ if (fromAccount == null && toAccount == null) {
+ response = ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND;
+ } else if (toAccount == null) {
+ response = ResponseReason.TO_ACCOUNT_NOT_FOUND;
+ } else if (fromAccount == null) {
+ response = ResponseReason.FROM_ACCOUNT_NOT_FOUND;
+ } else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) {
+ response = ResponseReason.INVALID_BOOKING_ACCOUNTS;
+ } else if (amount == null) {
+ response = ResponseReason.MISSING_AMOUNT;
+ } else if (amount == 0L) {
+ response = ResponseReason.AMOUNT_ZERO;
+ } else if (StringUtils.isEmpty(holidayWeekendType)) {
+ response = ResponseReason.MISSING_HOLIDAY_WEEKEND_TYPE;
+ } else if (!HolidayWeekendType.isValidType(holidayWeekendType)) {
+ response = ResponseReason.INVALID_HOLIDAY_WEEKEND_TYPE;
+ } else if (StringUtils.isEmpty(intervalType)) {
+ response = ResponseReason.MISSING_INTERVAL_TYPE;
+ } else if (!IntervalType.isValidType(intervalType)) {
+ response = ResponseReason.INVALID_INTERVAL_TYPE;
+ } else if (StringUtils.isEmpty(firstOccurrence)) {
+ response = ResponseReason.MISSING_FIRST_OCCURRENCE;
+ }
+
+ if (response == null && StringUtils.isNotEmpty(firstOccurrence)) {
+ try {
+ LocalDate.parse(firstOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()));
+ } catch (DateTimeParseException e) {
+ response = ResponseReason.INVALID_FIRST_OCCURRENCE_FORMAT;
+ }
+ }
+
+ if (response == null && StringUtils.isNotEmpty(lastOccurrence)) {
+ try {
+ LocalDate.parse(lastOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()));
+ } catch (DateTimeParseException e) {
+ response = ResponseReason.INVALID_LAST_OCCURRENCE_FORMAT;
+ }
+ }
+
+ return response;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason createTransaction(String recurringTransactionId, Optional
+ * 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.
+ *
+ * @param fromAccount the from account to get the multiplier for
+ *
+ * @return the multiplier, either 1 or -1
+ */
+ public long getMultiplierFromAccount(Account fromAccount) {
+ // There is no multiplier if the from account is an EXPENSE account because
+ // it's not a valid from account type
+
+ final AccountType accountType = fromAccount.getType();
+
+ if (INCOME.equals(accountType)) {
+ return 1L;
+ } else if (BANK.equals(accountType)) {
+ return -1L;
+ } else if (CASH.equals(accountType)) {
+ return -1L;
+ } else if (LIABILITY.equals(accountType)) {
+ return 1L;
+ } else if (START.equals(accountType)) {
+ return 1L;
+ }
+
+ LOGGER.warn(String
+ .format("Unknown or invalid account type in getMultiplierFromAccount: %s", accountType.name()));
+
+ return 1L;
+ }
+
+ /**
+ * 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.
+ *
+ * @param toAccount the to account to get the multiplier for
+ *
+ * @return the multiplier, either
+ * 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.
+ * 1 or -1
+ */
+ public long getMultiplierToAccount(Account toAccount) {
+ // There are no multipliers for INCOME and START accounts
+ // because they are not valid to account types
+
+ final AccountType accountType = toAccount.getType();
+
+ if (BANK.equals(accountType)) {
+ return 1L;
+ } else if (CASH.equals(accountType)) {
+ return 1L;
+ } else if (LIABILITY.equals(accountType)) {
+ return -1L;
+ } else if (EXPENSE.equals(accountType)) {
+ return 1L;
+ }
+
+ LOGGER.warn(String
+ .format("Unknown or invalid account type in getMultiplierToAccount: %s", accountType.name()));
+
+ return -1L;
+ }
+
+ /**
+ * This method validates whether the booking from fromAccount to toAccount is valid, e.g.
+ * booking directly from an {@link AccountType#INCOME INCOME} to an {@link AccountType#EXPENSE EXPENSE} 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 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) {
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug(String.format("Use state '%s' for holiday calculation", this.financerConfig.getState()));
+ }
+
+ 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/financer-server/src/main/java/de/financer/service/TransactionService.java b/financer-server/src/main/java/de/financer/service/TransactionService.java
new file mode 100644
index 0000000..1cfa3cc
--- /dev/null
+++ b/financer-server/src/main/java/de/financer/service/TransactionService.java
@@ -0,0 +1,228 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.config.FinancerConfig;
+import de.financer.dba.TransactionRepository;
+import de.financer.model.Account;
+import de.financer.model.AccountType;
+import de.financer.model.RecurringTransaction;
+import de.financer.model.Transaction;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.Optional;
+
+@Service
+public class TransactionService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(TransactionService.class);
+
+ @Autowired
+ private AccountService accountService;
+
+ @Autowired
+ private RuleService ruleService;
+
+ @Autowired
+ private TransactionRepository transactionRepository;
+
+ @Autowired
+ private FinancerConfig financerConfig;
+
+ /**
+ * @return all transactions, for all accounts and all time
+ */
+ public Iterablenull
+ *
+ * @return the build {@link Transaction} instance
+ */
+ private Transaction buildTransaction(Account fromAccount, Account toAccount, Long amount, String description,
+ String date, RecurringTransaction recurringTransaction
+ ) {
+ final Transaction transaction = new Transaction();
+
+ transaction.setFromAccount(fromAccount);
+ transaction.setToAccount(toAccount);
+ transaction.setAmount(amount);
+ transaction.setDescription(description);
+ transaction.setDate(LocalDate.parse(date, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())));
+ transaction.setRecurringTransaction(recurringTransaction);
+
+ return transaction;
+ }
+
+ /**
+ * This method checks whether the parameters for creating a transaction are valid.
+ *
+ * @param fromAccount the from account
+ * @param toAccount the to account
+ * @param amount the transaction amount
+ * @param date the transaction date
+ *
+ * @return the first error found or null if all parameters are valid
+ */
+ private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, String date) {
+ ResponseReason response = null;
+
+ if (fromAccount == null && toAccount == null) {
+ response = ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND;
+ } else if (toAccount == null) {
+ response = ResponseReason.TO_ACCOUNT_NOT_FOUND;
+ } else if (fromAccount == null) {
+ response = ResponseReason.FROM_ACCOUNT_NOT_FOUND;
+ } else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) {
+ response = ResponseReason.INVALID_BOOKING_ACCOUNTS;
+ } else if (amount == null) {
+ response = ResponseReason.MISSING_AMOUNT;
+ } else if (amount == 0L) {
+ response = ResponseReason.AMOUNT_ZERO;
+ } else if (StringUtils.isEmpty(date)) {
+ response = ResponseReason.MISSING_DATE;
+ } else if (StringUtils.isNotEmpty(date)) {
+ try {
+ LocalDate.parse(date, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()));
+ } catch (DateTimeParseException e) {
+ response = ResponseReason.INVALID_DATE_FORMAT;
+ }
+ }
+
+ return response;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason deleteTransaction(String transactionId) {
+ ResponseReason response = ResponseReason.OK;
+
+ if (transactionId == null) {
+ return ResponseReason.MISSING_TRANSACTION_ID;
+ } else if (!NumberUtils.isCreatable(transactionId)) {
+ return ResponseReason.INVALID_TRANSACTION_ID;
+ }
+
+ final Optional>() {});
+
+ Assert.assertEquals(23, allAccounts.size());
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java
new file mode 100644
index 0000000..1b7cf20
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java
@@ -0,0 +1,60 @@
+package de.financer.controller.integration;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.financer.FinancerApplication;
+import de.financer.model.RecurringTransaction;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.List;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FinancerApplication.class)
+@AutoConfigureMockMvc
+@TestPropertySource(
+ locations = "classpath:application-integrationtest.properties")
+public class RecurringTransactionService_createRecurringTransactionIntegrationTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ public void test_createRecurringTransaction() throws Exception {
+ final MvcResult mvcRequest = this.mockMvc.perform(get("/recurringTransactions/createRecurringTransaction")
+ .param("fromAccountKey", "Income")
+ .param("toAccountKey", "Check account")
+ .param("amount", "250000")
+ .param("description", "Monthly rent")
+ .param("holidayWeekendType", "SAME_DAY")
+ .param("intervalType", "MONTHLY")
+ .param("firstOccurrence", "07.03.2019")
+ .param("remind", "true"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ final MvcResult mvcResult = this.mockMvc.perform(get("/recurringTransactions/getAll")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ final List
>() {});
+
+ Assert.assertEquals(4, allRecurringTransaction.size());
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java
new file mode 100644
index 0000000..c19d396
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java
@@ -0,0 +1,68 @@
+package de.financer.controller.integration;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.financer.FinancerApplication;
+import de.financer.model.RecurringTransaction;
+import de.financer.model.Transaction;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FinancerApplication.class)
+@AutoConfigureMockMvc
+@TestPropertySource(
+ locations = "classpath:application-integrationtest.properties")
+public class RecurringTransactionService_createTransactionIntegrationTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ public void test_createTransaction() throws Exception {
+ final MvcResult mvcResultAll = this.mockMvc.perform(get("/recurringTransactions/getAll")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ final List
>() {});
+ final Optional
>() {});
+
+ Assert.assertEquals(1, allTransactions.size());
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java
new file mode 100644
index 0000000..f170a6f
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java
@@ -0,0 +1,49 @@
+package de.financer.controller.integration;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.financer.FinancerApplication;
+import de.financer.model.RecurringTransaction;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.List;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FinancerApplication.class)
+@AutoConfigureMockMvc
+@TestPropertySource(
+ locations = "classpath:application-integrationtest.properties")
+public class RecurringTransactionService_getAllActiveIntegrationTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ public void test_getAll() throws Exception {
+ final MvcResult mvcResult = this.mockMvc
+ .perform(get("/recurringTransactions/getAllActive").contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ final List
>() {});
+
+ Assert.assertEquals(3, allRecurringTransactions.size());
+ }
+
+}
diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java
new file mode 100644
index 0000000..abea31f
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java
@@ -0,0 +1,49 @@
+package de.financer.controller.integration;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.financer.FinancerApplication;
+import de.financer.model.RecurringTransaction;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.List;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FinancerApplication.class)
+@AutoConfigureMockMvc
+@TestPropertySource(
+ locations = "classpath:application-integrationtest.properties")
+public class RecurringTransactionService_getAllIntegrationTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ public void test_getAll() throws Exception {
+ final MvcResult mvcResult = this.mockMvc
+ .perform(get("/recurringTransactions/getAll").contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ final List
>() {});
+
+ Assert.assertEquals(4, allRecurringTransactions.size());
+ }
+
+}
diff --git a/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java b/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java
new file mode 100644
index 0000000..9f097db
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java
@@ -0,0 +1,62 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.dba.AccountGroupRepository;
+import de.financer.model.AccountGroup;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.dao.DataIntegrityViolationException;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AccountGroupService_createAccountGroupTest {
+
+ @InjectMocks
+ private AccountGroupService classUnderTest;
+
+ @Mock
+ private AccountGroupRepository accountGroupRepository;
+
+ @Test
+ public void test_createAccount_UNKNOWN_ERROR() {
+ // Arrange
+ Mockito.doThrow(new NullPointerException()).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class));
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccountGroup("Test");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
+ }
+
+ @Test
+ public void test_createAccount_OK() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccountGroup("Test");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.OK, response);
+ Mockito.verify(this.accountGroupRepository, Mockito.times(1))
+ .save(ArgumentMatchers.argThat((ag) -> "Test".equals(ag.getName())));
+ }
+
+ @Test
+ public void test_createAccount_DUPLICATE_ACCOUNT_GROUP_NAME() {
+ // Arrange
+ Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class));
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccountGroup("Test");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME, response);
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java b/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java
new file mode 100644
index 0000000..400b77a
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java
@@ -0,0 +1,90 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.dba.AccountRepository;
+import de.financer.model.Account;
+import de.financer.model.AccountGroup;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.dao.DataIntegrityViolationException;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AccountService_createAccountTest {
+ @InjectMocks
+ private AccountService classUnderTest;
+
+ @Mock
+ private AccountGroupService accountGroupService;
+
+ @Mock
+ private AccountRepository accountRepository;
+
+ @Test
+ public void test_createAccount_INVALID_ACCOUNT_TYPE() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccount(null, null, null);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_ACCOUNT_TYPE, response);
+ }
+
+ @Test
+ public void test_createAccount_UNKNOWN_ERROR() {
+ // Arrange
+ Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class));
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
+ }
+
+ @Test
+ public void test_createAccount_OK() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.OK, response);
+ Mockito.verify(this.accountRepository, Mockito.times(1))
+ .save(ArgumentMatchers.argThat((acc) -> "Test".equals(acc.getKey())));
+ }
+
+ @Test
+ public void test_createAccount_ACCOUNT_GROUP_NOT_FOUND() {
+ // Arrange
+ Mockito.when(this.accountGroupService.getAccountGroupByName(Mockito.anyString()))
+ .thenReturn(null);
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", "Group1");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.ACCOUNT_GROUP_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_createAccount_DUPLICATE_ACCOUNT_KEY() {
+ // Arrange
+ Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountRepository).save(Mockito.any(Account.class));
+
+ // Act
+ ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_KEY, response);
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java b/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java
new file mode 100644
index 0000000..6c0ce2c
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java
@@ -0,0 +1,62 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.dba.AccountRepository;
+import de.financer.model.Account;
+import de.financer.model.AccountStatus;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AccountService_setAccountStatusTest {
+ @InjectMocks
+ private AccountService classUnderTest;
+
+ @Mock
+ private AccountRepository accountRepository;
+
+ @Test
+ public void test_setAccountStatus_ACCOUNT_NOT_FOUND() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.ACCOUNT_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_setAccountStatus_UNKNOWN_ERROR() {
+ // Arrange
+ Mockito.when(this.accountRepository.findByKey(Mockito.anyString())).thenReturn(new Account());
+ Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class));
+
+ // Act
+ ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
+ }
+
+ @Test
+ public void test_setAccountStatus_OK() {
+ // Arrange
+ Mockito.when(this.accountRepository.findByKey(Mockito.anyString())).thenReturn(new Account());
+
+ // Act
+ ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.OK, response);
+ Mockito.verify(this.accountRepository, Mockito.times(1))
+ .save(ArgumentMatchers.argThat((acc) -> AccountStatus.CLOSED.equals(acc.getStatus())));
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java
new file mode 100644
index 0000000..c34cf0d
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java
@@ -0,0 +1,361 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.config.FinancerConfig;
+import de.financer.dba.RecurringTransactionRepository;
+import de.financer.model.Account;
+import de.financer.model.HolidayWeekendType;
+import de.financer.model.IntervalType;
+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;
+
+@RunWith(MockitoJUnitRunner.class)
+public class RecurringTransactionService_createRecurringTransactionTest {
+ @InjectMocks
+ private RecurringTransactionService classUnderTest;
+
+ @Mock
+ private AccountService accountService;
+
+ @Mock
+ private RuleService ruleService;
+
+ @Mock
+ private RecurringTransactionRepository recurringTransactionRepository;
+
+ @Mock
+ private FinancerConfig financerConfig;
+
+ @Before
+ public void setUp() {
+ Mockito.when(this.financerConfig.getDateFormat()).thenReturn("dd.MM.yyyy");
+ }
+
+ @Test
+ public void test_createRecurringTransaction_FROM_AND_TO_ACCOUNT_NOT_FOUND() {
+ // Arrange
+ // Nothing to do, if we do not instruct the account service instance to return anything the accounts
+ // will not be found.
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid",
+ "account.invalid",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_TO_ACCOUNT_NOT_FOUND() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), null);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.invalid",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.TO_ACCOUNT_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_FROM_ACCOUNT_NOT_FOUND() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(null, createAccount());
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.FROM_ACCOUNT_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_INVALID_BOOKING_ACCOUNTS() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.FALSE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_BOOKING_ACCOUNTS, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_MISSING_AMOUNT() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ null,
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.MISSING_AMOUNT, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_AMOUNT_ZERO() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(0l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.AMOUNT_ZERO, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_MISSING_HOLIDAY_WEEKEND_TYPE() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ null,
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.MISSING_HOLIDAY_WEEKEND_TYPE, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_INVALID_HOLIDAY_WEEKEND_TYPE() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ "HOLIDAY_WEEKEND_TYPE",
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_HOLIDAY_WEEKEND_TYPE, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_MISSING_INTERVAL_TYPE() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ null,
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.MISSING_INTERVAL_TYPE, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_INVALID_INTERVAL_TYPE() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ "INTERVAL_TYPE",
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_INTERVAL_TYPE, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_MISSING_FIRST_OCCURRENCE() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ IntervalType.DAILY.name(),
+ null,
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.MISSING_FIRST_OCCURRENCE, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_INVALID_FIRST_OCCURRENCE_FORMAT() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ IntervalType.DAILY.name(),
+ "FIRST_OCCURRENCE",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_FIRST_OCCURRENCE_FORMAT, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_INVALID_LAST_OCCURRENCE_FORMAT() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ IntervalType.DAILY.name(),
+ "07.03.2019",
+ "LAST_OCCURRENCE",
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_LAST_OCCURRENCE_FORMAT, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_UNKNOWN_ERROR() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+ Mockito.when(this.recurringTransactionRepository.save(Mockito.any())).thenThrow(new NullPointerException());
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ IntervalType.DAILY.name(),
+ "07.03.2019",
+ null,
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
+ }
+
+ @Test
+ public void test_createRecurringTransaction_OK() {
+ // Arrange
+ Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount());
+ Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE);
+
+ // Act
+ final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
+ "account.to",
+ Long.valueOf(150l),
+ "DESCRIPTION",
+ HolidayWeekendType.SAME_DAY.name(),
+ IntervalType.DAILY.name(),
+ "07.03.2019",
+ null,
+ Boolean.TRUE);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.OK, response);
+ }
+
+ private Account createAccount() {
+ final Account account = new Account();
+
+ account.setCurrentBalance(Long.valueOf(0l));
+
+ return account;
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java
new file mode 100644
index 0000000..8a44a1c
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java
@@ -0,0 +1,94 @@
+package de.financer.service;
+
+import de.financer.ResponseReason;
+import de.financer.config.FinancerConfig;
+import de.financer.dba.RecurringTransactionRepository;
+import de.financer.model.RecurringTransaction;
+import org.junit.Assert;
+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.util.Optional;
+
+@RunWith(MockitoJUnitRunner.class)
+public class RecurringTransactionService_deleteRecurringTransactionTest {
+ @InjectMocks
+ private RecurringTransactionService classUnderTest;
+
+ @Mock
+ private AccountService accountService;
+
+ @Mock
+ private RuleService ruleService;
+
+ @Mock
+ private RecurringTransactionRepository recurringTransactionRepository;
+
+ @Mock
+ private FinancerConfig financerConfig;
+
+ @Test
+ public void test_deleteRecurringTransaction_MISSING_RECURRING_TRANSACTION_ID() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ final ResponseReason response = this.classUnderTest.deleteRecurringTransaction(null);
+
+ // Assert
+ Assert.assertEquals(ResponseReason.MISSING_RECURRING_TRANSACTION_ID, response);
+ }
+
+ @Test
+ public void test_deleteRecurringTransaction_INVALID_RECURRING_TRANSACTION_ID() {
+ // Arrange
+ // Nothing to do
+
+ // Act
+ final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("invalid");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.INVALID_RECURRING_TRANSACTION_ID, response);
+ }
+
+ @Test
+ public void test_deleteRecurringTransaction_RECURRING_TRANSACTION_NOT_FOUND() {
+ // Arrange
+ Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.empty());
+
+ // Act
+ final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.RECURRING_TRANSACTION_NOT_FOUND, response);
+ }
+
+ @Test
+ public void test_deleteRecurringTransaction_UNKNOWN_ERROR() {
+ // Arrange
+ Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.of(new RecurringTransaction()));
+ Mockito.doThrow(new NullPointerException()).when(this.recurringTransactionRepository).save(Mockito.any());
+
+ // Act
+ final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
+ }
+
+ @Test
+ public void test_deleteRecurringTransaction_OK() {
+ // Arrange
+ Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.of(new RecurringTransaction()));
+
+ // Act
+ final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123");
+
+ // Assert
+ Assert.assertEquals(ResponseReason.OK, response);
+ }
+}
diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java
new file mode 100644
index 0000000..0471644
--- /dev/null
+++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java
@@ -0,0 +1,143 @@
+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
+ .findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
+ .thenReturn(Collections.singletonList(createRecurringTransaction(-3)));
+ final LocalDate now = LocalDate.now();
+
+ // Act
+ final Iterable