From 35902afe431b31710c4b60b7f14b31af3396764c Mon Sep 17 00:00:00 2001 From: MK13 Date: Fri, 1 Mar 2019 20:39:31 +0100 Subject: [PATCH] Various stuff all over the tree - Increase Java version to 1.9 - Add commons-collection and Jollyday dependencies - Add JavaDoc plugin - Add country and state configuration for Jollyday library - Add WIP implementation of the recurring transaction feature - Improve JavaDoc - Use Java 8 date API - Reformatting - Add special Flyway migration version for test data - Add and improve unit tests --- pom.xml | 31 +++- .../de/financer/config/FinancerConfig.java | 53 ++++++ .../RecurringTransactionController.java | 43 +++++ .../controller/TransactionController.java | 8 +- .../dba/RecurringTransactionRepository.java | 5 + .../de/financer/model/HolidayWeekendType.java | 41 ++++- .../financer/model/RecurringTransaction.java | 16 +- .../java/de/financer/model/Transaction.java | 9 +- .../java/de/financer/model/package-info.java | 10 ++ .../service/RecurringTransactionService.java | 163 ++++++++++++++++++ .../java/de/financer/service/RuleService.java | 90 +++++++--- .../financer/service/TransactionService.java | 55 +++--- .../de/financer/service/package-info.java | 9 + .../resources/config/application.properties | 11 +- .../database/hsqldb/V1_0_0__init.sql | 16 +- .../AccountControllerIntegrationTest.java | 12 +- ...getAllDueToday_DAILY_NEXT_WORKDAYTest.java | 134 ++++++++++++++ ...tAllDueToday_MONTHLY_NEXT_WORKDAYTest.java | 156 +++++++++++++++++ ...nsactionService_createTransactionTest.java | 158 +++++++++++++++++ .../application-integrationtest.properties | 10 +- .../integration/V999_99_00__testdata.sql | 22 +++ 21 files changed, 950 insertions(+), 102 deletions(-) create mode 100644 src/main/java/de/financer/config/FinancerConfig.java create mode 100644 src/main/java/de/financer/controller/RecurringTransactionController.java create mode 100644 src/main/java/de/financer/model/package-info.java create mode 100644 src/main/java/de/financer/service/RecurringTransactionService.java create mode 100644 src/main/java/de/financer/service/package-info.java create mode 100644 src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java create mode 100644 src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java create mode 100644 src/test/java/de/financer/service/TransactionService_createTransactionTest.java create mode 100644 src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql diff --git a/pom.xml b/pom.xml index af9934f..8fb3e96 100644 --- a/pom.xml +++ b/pom.xml @@ -20,9 +20,9 @@ UTF-8 - 1.8 - 1.8 - 1.8 + 1.9 + 1.9 + 1.9 @@ -46,7 +46,17 @@ org.apache.commons commons-lang3 - 3.8.1 + + + org.apache.commons + commons-collections4 + 4.3 + + + + de.jollyday + jollyday + 0.5.7 @@ -99,6 +109,19 @@ + + + + org.apache.maven.plugins + maven-javadoc-plugin + + private + /usr/bin/javadoc + + + + + integration-tests diff --git a/src/main/java/de/financer/config/FinancerConfig.java b/src/main/java/de/financer/config/FinancerConfig.java new file mode 100644 index 0000000..a3a549a --- /dev/null +++ b/src/main/java/de/financer/config/FinancerConfig.java @@ -0,0 +1,53 @@ +package de.financer.config; + +import de.jollyday.HolidayCalendar; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.Optional; + +@Configuration +@ConfigurationProperties(prefix = "financer") +public class FinancerConfig { + private String countryCode; + private String state; + + /** + * @return the raw country code, mostly an uppercase ISO 3166 2-letter code + */ + public String getCountryCode() { + return countryCode; + } + + /** + * @return the state + */ + public String getState() { + return state; + } + + /** + * @return the {@link HolidayCalendar} used to calculate the holidays. Internally uses the country code + * specified via {@link FinancerConfig#getCountryCode}. + */ + public HolidayCalendar getHolidayCalendar() { + final Optional optionalHoliday = Arrays.asList(HolidayCalendar.values()).stream() + .filter((hc) -> hc.getId().equals(this.countryCode)) + .findFirst(); + + if (!optionalHoliday.isPresent()) { + // TODO log info about default DE + } + + return optionalHoliday.orElse(HolidayCalendar.GERMANY); + } + + public void setState(String state) { + this.state = state; + } + + public void setCountryCode(String countryCode) { + this.countryCode = countryCode; + } +} diff --git a/src/main/java/de/financer/controller/RecurringTransactionController.java b/src/main/java/de/financer/controller/RecurringTransactionController.java new file mode 100644 index 0000000..5c4a72b --- /dev/null +++ b/src/main/java/de/financer/controller/RecurringTransactionController.java @@ -0,0 +1,43 @@ +package de.financer.controller; + +import de.financer.model.RecurringTransaction; +import de.financer.service.RecurringTransactionService; +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; + +@RestController +@RequestMapping("recurringTransactions") +public class RecurringTransactionController { + + @Autowired + private RecurringTransactionService recurringTransactionService; + + @RequestMapping("getAll") + public Iterable getAll() { + return this.recurringTransactionService.getAll(); + } + + @RequestMapping("getAllForAccount") + public Iterable getAllForAccount(String accountKey) { + return this.recurringTransactionService.getAllForAccount(accountKey); + } + + @RequestMapping("getAllDueToday") + public Iterable getAllDueToday() { + return this.recurringTransactionService.getAllDueToday(); + } + + @RequestMapping("createRecurringTransaction") + public ResponseEntity createRecurringTransaction(String fromAccountKey, String toAccountKey, Long amount, + String description, String holidayWeekendType, + String intervalType, String firstOccurrence, + String lastOccurrence) { + return this.recurringTransactionService.createRecurringTransaction(fromAccountKey, toAccountKey, amount, + description, holidayWeekendType, + intervalType, firstOccurrence, + lastOccurrence) + .toResponseEntity(); + } +} diff --git a/src/main/java/de/financer/controller/TransactionController.java b/src/main/java/de/financer/controller/TransactionController.java index aba4b6a..bb14c44 100644 --- a/src/main/java/de/financer/controller/TransactionController.java +++ b/src/main/java/de/financer/controller/TransactionController.java @@ -23,8 +23,10 @@ public class TransactionController { return this.transactionService.getAllForAccount(accountKey); } - @RequestMapping("createTransaction") - public ResponseEntity createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date, String description) { - return this.transactionService.createTransaction(fromAccountKey, toAccountKey, amount, date, description).toResponseEntity(); + @RequestMapping(value = "createTransaction") + public ResponseEntity createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date, + String description) { + return this.transactionService.createTransaction(fromAccountKey, toAccountKey, amount, date, description) + .toResponseEntity(); } } diff --git a/src/main/java/de/financer/dba/RecurringTransactionRepository.java b/src/main/java/de/financer/dba/RecurringTransactionRepository.java index 430cd06..dffe6d5 100644 --- a/src/main/java/de/financer/dba/RecurringTransactionRepository.java +++ b/src/main/java/de/financer/dba/RecurringTransactionRepository.java @@ -1,7 +1,12 @@ package de.financer.dba; +import de.financer.model.Account; import de.financer.model.RecurringTransaction; import org.springframework.data.repository.CrudRepository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +@Transactional(propagation = Propagation.REQUIRED) public interface RecurringTransactionRepository extends CrudRepository { + Iterable findRecurringTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount); } diff --git a/src/main/java/de/financer/model/HolidayWeekendType.java b/src/main/java/de/financer/model/HolidayWeekendType.java index cd2cf20..3de89b0 100644 --- a/src/main/java/de/financer/model/HolidayWeekendType.java +++ b/src/main/java/de/financer/model/HolidayWeekendType.java @@ -10,10 +10,47 @@ public enum HolidayWeekendType { /** Indicates that the action should be done on the specified day regardless whether it's a holiday or a weekend */ SAME_DAY, - /** Indicates that the action should be deferred to the next workday */ + /** + *

+ * 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 Iterable getAll() { + return this.recurringTransactionRepository.findAll(); + } + + public Iterable getAllForAccount(String accountKey) { + final Account account = this.accountService.getAccountByKey(accountKey); + + if (account == null) { + return Collections.emptyList(); + } + + // As we want all transactions of the given account use it as from and to account + return this.recurringTransactionRepository.findRecurringTransactionsByFromAccountOrToAccount(account, account); + } + + /** + * This method gets all recurring transactions that are due today. Whether a recurring transaction is due today + * depends on today's date and the configured {@link RecurringTransaction#getIntervalType() interval type} + * and {@link RecurringTransaction#getHolidayWeekendType() holiday weekend type}. + * + * @return all recurring transactions that are due today + */ + public Iterable getAllDueToday() { + return this.getAllDueToday(LocalDate.now()); + } + + // Visible for unit tests + /* package */ Iterable getAllDueToday(LocalDate now) { + // TODO filter for lastOccurrence not in the past + final Iterable allRecurringTransactions = this.recurringTransactionRepository.findAll(); + + //@formatter:off + return IterableUtils.toList(allRecurringTransactions).stream() + .filter((rt) -> checkRecurringTransactionDueToday(rt, now) || + checkRecurringTransactionDuePast(rt, now)) + // TODO checkRecurringTransactionDueFuture for HolidayWeekendType.PREVIOUS_WORKDAY + .collect(Collectors.toList()); + //@formatter:on + } + + /** + * This method checks whether the given {@link RecurringTransaction} is due today. + * A recurring transaction is due if the current {@link LocalDate date} is a multiple of the + * {@link RecurringTransaction#getFirstOccurrence() first occurrence} of the recurring transaction and the + * {@link RecurringTransaction#getIntervalType() interval type}. If today is a + * {@link RuleService#isHoliday(LocalDate) holiday} or a + * {@link RuleService#isWeekend(LocalDate) weekend day} the + * {@link HolidayWeekendType holiday weekend type} + * is taken into account to decide whether the recurring transaction should be 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 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> bookingRules; + private Map intervalPeriods; @Override public void afterPropertiesSet() { initBookingRules(); + initIntervalValues(); + } + + private void initIntervalValues() { + this.intervalPeriods = new EnumMap<>(IntervalType.class); + + this.intervalPeriods.put(IntervalType.DAILY, Period.ofDays(1)); + this.intervalPeriods.put(IntervalType.WEEKLY, Period.ofWeeks(1)); + this.intervalPeriods.put(IntervalType.MONTHLY, Period.ofMonths(1)); + this.intervalPeriods.put(IntervalType.QUARTERLY, Period.ofMonths(3)); + this.intervalPeriods.put(IntervalType.YEARLY, Period.ofYears(1)); } private void initBookingRules() { @@ -40,7 +64,7 @@ public class RuleService implements InitializingBean { /** * This method returns the multiplier for the given from account. - * + *

* 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 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 Iterable getAllForAccount(String accountKey) { final Account account = this.accountService.getAccountByKey(accountKey); @@ -63,8 +64,10 @@ public class TransactionService { try { final Transaction transaction = buildTransaction(fromAccount, toAccount, amount, description, date); - fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService.getMultiplierFromAccount(fromAccount) * amount)); - toAccount.setCurrentBalance(toAccount.getCurrentBalance() + (this.ruleService.getMultiplierToAccount(toAccount) * amount)); + fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService + .getMultiplierFromAccount(fromAccount) * amount)); + toAccount.setCurrentBalance(toAccount.getCurrentBalance() + (this.ruleService + .getMultiplierToAccount(toAccount) * amount)); this.transactionRepository.save(transaction); @@ -72,13 +75,11 @@ public class TransactionService { this.accountService.saveAccount(toAccount); response = ResponseReason.OK; - } - catch(ParseException e) { + } catch (DateTimeParseException e) { // TODO log response = ResponseReason.INVALID_DATE_FORMAT; - } - catch (Exception e) { + } catch (Exception e) { // TODO log response = ResponseReason.UNKNOWN_ERROR; @@ -91,21 +92,21 @@ public class TransactionService { * This method builds the actual transaction object with the given values. * * @param fromAccount the from account - * @param toAccount the to account - * @param amount the transaction amount + * @param toAccount the to account + * @param amount the transaction amount * @param description the description of the transaction - * @param date the date of the transaction + * @param date the date of the transaction * @return the build {@link Transaction} instance - * @throws ParseException if the given date string cannot be parsed into a {@link java.util.Date} instance + * @throws DateTimeParseException if the given date string cannot be parsed into a {@link java.time.LocalDate} instance */ - private Transaction buildTransaction(Account fromAccount, Account toAccount, Long amount, String description, String date) throws ParseException { + private Transaction buildTransaction(Account fromAccount, Account toAccount, Long amount, String description, String date) throws DateTimeParseException { final Transaction transaction = new Transaction(); transaction.setFromAccount(fromAccount); transaction.setToAccount(toAccount); transaction.setAmount(amount); transaction.setDescription(description); - transaction.setDate(new SimpleDateFormat(DATE_FORMAT).parse(date)); + transaction.setDate(LocalDate.parse(date, DateTimeFormatter.ofPattern(DATE_FORMAT))); return transaction; } @@ -114,9 +115,9 @@ public class TransactionService { * 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 + * @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) { @@ -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 @@ +/** + *

+ * 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. + *

+ */ +package de.financer.service; \ No newline at end of file diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index 0c43199..00c8e39 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -12,4 +12,13 @@ info.app.name=Financer info.app.description=A simple server for personal finance administration info.build.group=@project.groupId@ info.build.artifact=@project.artifactId@ -info.build.version=@project.version@ \ No newline at end of file +info.build.version=@project.version@ + +# Country code for holiday checks +# Mostly an uppercase ISO 3166 2-letter code +# For a complete list of the supported codes see https://github.com/svendiedrichsen/jollyday/blob/master/src/main/java/de/jollyday/HolidayCalendar.java +financer.countryCode=DE + +# The state used for holiday checks +# For a complete list of the supported states see e.g. https://github.com/svendiedrichsen/jollyday/blob/master/src/main/resources/holidays/Holidays_de.xml +financer.state=sl \ No newline at end of file diff --git a/src/main/resources/database/hsqldb/V1_0_0__init.sql b/src/main/resources/database/hsqldb/V1_0_0__init.sql index 16070f2..1cfbe37 100644 --- a/src/main/resources/database/hsqldb/V1_0_0__init.sql +++ b/src/main/resources/database/hsqldb/V1_0_0__init.sql @@ -1,8 +1,8 @@ -- --- This file contains the basic initialization of the financer schema and init data +-- This file contains the basic initialization of the financer schema -- --- Account table and init data +-- Account table CREATE TABLE account ( id BIGINT NOT NULL PRIMARY KEY IDENTITY, "key" VARCHAR(1000) NOT NULL, --escape keyword "key" @@ -13,18 +13,6 @@ CREATE TABLE account ( CONSTRAINT un_account_name_key UNIQUE ("key") ); -INSERT INTO account ("key", type, status, current_balance) -VALUES ('accounts.checkaccount', 'BANK', 'OPEN', 0); - -INSERT INTO account ("key", type, status, current_balance) -VALUES ('accounts.income', 'INCOME', 'OPEN', 0); - -INSERT INTO account ("key", type, status, current_balance) -VALUES ('accounts.cash', 'CASH', 'OPEN', 0); - -INSERT INTO account ("key", type, status, current_balance) -VALUES ('accounts.start', 'START', 'OPEN', 0); - -- Recurring transaction table CREATE TABLE recurring_transaction ( id BIGINT NOT NULL PRIMARY KEY IDENTITY, diff --git a/src/test/java/de/financer/controller/integration/AccountControllerIntegrationTest.java b/src/test/java/de/financer/controller/integration/AccountControllerIntegrationTest.java index 6fdf02e..910b4c3 100644 --- a/src/test/java/de/financer/controller/integration/AccountControllerIntegrationTest.java +++ b/src/test/java/de/financer/controller/integration/AccountControllerIntegrationTest.java @@ -36,12 +36,14 @@ public class AccountControllerIntegrationTest { @Test public void test_getAll() throws Exception { - final MvcResult mvcResult = this.mockMvc.perform(get("/accounts/getAll").contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andReturn(); + final MvcResult mvcResult = this.mockMvc + .perform(get("/accounts/getAll").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); - final List allAccounts = this.objectMapper.readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>(){}); + final List allAccounts = this.objectMapper + .readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>() {}); - 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 recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = today, intervalType = daily and + * holidayWeekendType = next_workday is not due on a holiday, non-weekend day + */ + @Test + public void test_getAllDueToday_dueToday_holiday() { + // Arrange + // Implicitly: ruleService.isWeekend().return(false) + Mockito.when(this.recurringTransactionRepository.findAll()).thenReturn(Collections.singletonList(createRecurringTransaction(0))); + // Today is a holiday, but yesterday was not + Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = today, intervalType = daily and + * holidayWeekendType = next_workday is not due on a non-holiday, weekend day + */ + @Test + public void test_getAllDueToday_dueToday_weekend() { + // Arrange + // Implicitly: ruleService.isHoliday().return(false) + Mockito.when(this.recurringTransactionRepository.findAll()).thenReturn(Collections.singletonList(createRecurringTransaction(0))); + // Today is a weekend day, but yesterday was not + Mockito.when(this.ruleService.isWeekend(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = tomorrow, intervalType = daily and + * holidayWeekendType = next_workday is not due today + */ + @Test + public void test_getAllDueToday_dueToday_tomorrow() { + // Arrange + // Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false) + Mockito.when(this.recurringTransactionRepository.findAll()).thenReturn(Collections.singletonList(createRecurringTransaction(1))); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + private RecurringTransaction createRecurringTransaction(int days) { + final RecurringTransaction recurringTransaction = new RecurringTransaction(); + + recurringTransaction.setFirstOccurrence(LocalDate.now().plusDays(days)); + + recurringTransaction.setHolidayWeekendType(HolidayWeekendType.NEXT_WORKDAY); + recurringTransaction.setIntervalType(IntervalType.DAILY); + + return recurringTransaction; + } +} diff --git a/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java b/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java new file mode 100644 index 0000000..9299dea --- /dev/null +++ b/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java @@ -0,0 +1,156 @@ +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; + +@RunWith(MockitoJUnitRunner.class) +public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest { + @InjectMocks + private RecurringTransactionService classUnderTest; + + @Mock + private RecurringTransactionRepository recurringTransactionRepository; + + @Mock + private RuleService ruleService; + + @Before + public void setUp() { + Mockito.when(this.ruleService.getPeriodForInterval(IntervalType.MONTHLY)).thenReturn(Period.ofMonths(1)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = one month and one day ago + * (and thus was actually due yesterday), intervalType = monthly and holidayWeekendType = next_workday is due today, + * if yesterday was a holiday but today is not + */ + @Test + public void test_getAllDueToday_duePast_holiday() { + // Arrange + Mockito.when(this.recurringTransactionRepository.findAll()) + .thenReturn(Collections.singletonList(createRecurringTransaction(-1))); + // Today is not a holiday but yesterday was + Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.TRUE); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = last friday one month ago + * (and thus was actually due last friday), intervalType = monthly and holidayWeekendType = next_workday is due + * today (monday), if friday was holiday + */ + @Test + public void test_getAllDueToday_duePast_weekend_friday_holiday() { + //@formatter:off + // MO TU WE TH FR SA SU -> Weekdays + // 1 2 3 4 5 6 7 -> Ordinal + // H WE WE -> Holiday/WeekEnd + // X -> Scheduled recurring transaction + // O -> now + // + // So now - (ordinal +- offset) + // now - (3 - 1) = previous MO + // now - 3 = previous SU + // now - (3 + 2) = previous FR + //@formatter:on + + // Arrange + final LocalDate now = LocalDate.now(); + final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1); + // The transaction occurs on a friday + Mockito.when(this.recurringTransactionRepository.findAll()) + .thenReturn(Collections.singletonList(createRecurringTransaction(-(now.getDayOfWeek().getValue() + 2)))); + // First False for the dueToday check, 2x True for actual weekend, second False for Friday + Mockito.when(this.ruleService.isWeekend(Mockito.any())) + .thenReturn(Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE); + // First False for the dueToday check, 2x False for actual weekend, True for Friday + Mockito.when(this.ruleService.isHoliday(Mockito.any())) + .thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, Boolean.TRUE); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(monday); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = last sunday a month ago + * (and thus was actually due last sunday/yesterday), intervalType = monthly and holidayWeekendType = next_workday + * is due today (monday) + */ + @Test + public void test_getAllDueToday_duePast_weekend_sunday() { + // Arrange + final LocalDate now = LocalDate.now(); + final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1); + // The transaction occurs on a sunday + Mockito.when(this.recurringTransactionRepository.findAll()) + .thenReturn(Collections.singletonList(createRecurringTransaction(-now.getDayOfWeek().getValue()))); + // First False for the dueToday check, 2x True for actual weekend, second False for Friday + Mockito.when(this.ruleService.isWeekend(Mockito.any())) + .thenReturn(Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(monday); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = saturday a month ago + * (and thus was actually due last saturday/two days ago), intervalType = monthly and + * holidayWeekendType = next_workday is due today (monday) + */ + @Test + public void test_getAllDueToday_duePast_weekend_saturday() { + // Arrange + final LocalDate now = LocalDate.now(); + final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1); + // The transaction occurs on a saturday + Mockito.when(this.recurringTransactionRepository.findAll()) + .thenReturn(Collections.singletonList(createRecurringTransaction(-(now.getDayOfWeek().getValue() + 1)))); + // First False for the dueToday check, 2x True for actual weekend, second False for Friday + Mockito.when(this.ruleService.isWeekend(Mockito.any())) + .thenReturn(Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(monday); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + private RecurringTransaction createRecurringTransaction(int days) { + final RecurringTransaction recurringTransaction = new RecurringTransaction(); + + recurringTransaction.setFirstOccurrence(LocalDate.now().plusDays(days).minusMonths(1)); + + recurringTransaction.setHolidayWeekendType(HolidayWeekendType.NEXT_WORKDAY); + recurringTransaction.setIntervalType(IntervalType.MONTHLY); + + return recurringTransaction; + } +} diff --git a/src/test/java/de/financer/service/TransactionService_createTransactionTest.java b/src/test/java/de/financer/service/TransactionService_createTransactionTest.java new file mode 100644 index 0000000..5c2fd07 --- /dev/null +++ b/src/test/java/de/financer/service/TransactionService_createTransactionTest.java @@ -0,0 +1,158 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.TransactionRepository; +import de.financer.model.Account; +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; + +@RunWith(MockitoJUnitRunner.class) +public class TransactionService_createTransactionTest { + @InjectMocks + private TransactionService classUnderTest; + + @Mock + private AccountService accountService; + + @Mock + private RuleService ruleService; + + @Mock + private TransactionRepository transactionRepository; + + @Test + public void test_createTransaction_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.createTransaction("account.invalid", "account.invalid", Long.valueOf(150l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createTransaction_TO_ACCOUNT_NOT_FOUND() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), null); + + // Act + final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", Long.valueOf(150l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.TO_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createTransaction_FROM_ACCOUNT_NOT_FOUND() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(null, createAccount()); + + // Act + final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", Long.valueOf(150l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.FROM_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createTransaction_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.createTransaction("account.invalid", "account.invalid", Long.valueOf(150l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_BOOKING_ACCOUNTS, response); + } + + @Test + public void test_createTransaction_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.createTransaction("account.invalid", "account.invalid", null, "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_AMOUNT, response); + } + + @Test + public void test_createTransaction_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.createTransaction("account.invalid", "account.invalid", Long.valueOf(0l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.AMOUNT_ZERO, response); + } + + @Test + public void test_createTransaction_MISSING_DATE() { + // 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.createTransaction("account.invalid", "account.invalid", Long.valueOf(125l), null, "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_DATE, response); + } + + @Test + public void test_createTransaction_INVALID_DATE_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.createTransaction("account.invalid", "account.invalid", Long.valueOf(125l), "2019-01-01", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_DATE_FORMAT, response); + } + + @Test + public void test_createTransaction_OK() { + // Arrange + final Account fromAccount = Mockito.mock(Account.class); + final Account toAccount = Mockito.mock(Account.class); + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(fromAccount, toAccount); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(), Mockito.any())).thenReturn(Boolean.TRUE); + Mockito.when(this.ruleService.getMultiplierFromAccount(Mockito.any())).thenReturn(Long.valueOf(-1l)); + Mockito.when(this.ruleService.getMultiplierToAccount(Mockito.any())).thenReturn(Long.valueOf(1l)); + Mockito.when(fromAccount.getCurrentBalance()).thenReturn(Long.valueOf(0l)); + Mockito.when(toAccount.getCurrentBalance()).thenReturn(Long.valueOf(0l)); + + // Act + final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", Long.valueOf(125l), "24.02.2019", "XXX"); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + Mockito.verify(fromAccount, Mockito.times(1)).setCurrentBalance(Long.valueOf(-125)); + Mockito.verify(toAccount, Mockito.times(1)).setCurrentBalance(Long.valueOf(125)); + } + + public Account createAccount() { + final Account account = new Account(); + + account.setCurrentBalance(Long.valueOf(0l)); + + return account; + } +} diff --git a/src/test/resources/application-integrationtest.properties b/src/test/resources/application-integrationtest.properties index d9b9c6a..b75a1d2 100644 --- a/src/test/resources/application-integrationtest.properties +++ b/src/test/resources/application-integrationtest.properties @@ -1,11 +1,3 @@ -### -### This is the main configuration file for integration tests -### - -server.servlet.context-path=/financer - -spring.jpa.hibernate.ddl-auto=validate - spring.datasource.url=jdbc:hsqldb:mem:. spring.datasource.username=sa -spring.flyway.locations=classpath:/database/hsqldb \ No newline at end of file +spring.flyway.locations=classpath:/database/hsqldb,classpath:/database/hsqldb/integration diff --git a/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql b/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql new file mode 100644 index 0000000..468578d --- /dev/null +++ b/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql @@ -0,0 +1,22 @@ +-- Accounts +INSERT INTO account (id, "key", type, status, current_balance) +VALUES (1, 'accounts.checkaccount', 'BANK', 'OPEN', 0); -- insert first with ID 1 so we get predictable numbering + +INSERT INTO account ("key", type, status, current_balance) +VALUES ('accounts.income', 'INCOME', 'OPEN', 0); + +INSERT INTO account ("key", type, status, current_balance) +VALUES ('accounts.cash', 'CASH', 'OPEN', 0); + +INSERT INTO account ("key", type, status, current_balance) +VALUES ('accounts.start', 'START', 'OPEN', 0); + +INSERT INTO account ("key", type, status, current_balance) +VALUES ('accounts.convenience', 'EXPENSE', 'OPEN', 0); + +--Recurring transactions +INSERT INTO recurring_transaction (from_account_id, to_account_id, description, amount, interval_type, first_occurrence, holiday_weekend_type) +VALUES (2, 1, 'Pay', 250000, 'MONTHLY', '2019-01-15', 'NEXT_WORKDAY'); + +INSERT INTO recurring_transaction (from_account_id, to_account_id, description, amount, interval_type, first_occurrence, holiday_weekend_type) +VALUES (3, 5, 'Pretzel', 170, 'DAILY', '2019-02-20', 'SAME_DAY'); \ No newline at end of file