Move content to financer-server/ subfolder as preparation for repository merge

This commit is contained in:
2019-06-20 14:29:33 +02:00
parent c5734f38c2
commit ff2ea0c68d
68 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
package de.financer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class FinancerApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(FinancerApplication.class);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(FinancerApplication.class);
}
}

View File

@@ -0,0 +1,45 @@
package de.financer;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public enum ResponseReason {
OK(HttpStatus.OK),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_ACCOUNT_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
FROM_ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
TO_ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
FROM_AND_TO_ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_DATE_FORMAT(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_DATE(HttpStatus.INTERNAL_SERVER_ERROR),
AMOUNT_ZERO(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_AMOUNT(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_BOOKING_ACCOUNTS(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_HOLIDAY_WEEKEND_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_HOLIDAY_WEEKEND_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_INTERVAL_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_INTERVAL_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_FIRST_OCCURRENCE(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_FIRST_OCCURRENCE_FORMAT(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_LAST_OCCURRENCE_FORMAT(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_RECURRING_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_RECURRING_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR),
RECURRING_TRANSACTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
MISSING_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR),
TRANSACTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
DUPLICATE_ACCOUNT_KEY(HttpStatus.INTERNAL_SERVER_ERROR),
DUPLICATE_ACCOUNT_GROUP_NAME(HttpStatus.INTERNAL_SERVER_ERROR),
ACCOUNT_GROUP_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR);
private HttpStatus httpStatus;
ResponseReason(HttpStatus httpStatus) {
this.httpStatus = httpStatus;
}
public ResponseEntity toResponseEntity() {
return new ResponseEntity<>(this.name(), this.httpStatus);
}
}

View File

@@ -0,0 +1,112 @@
package de.financer.config;
import de.jollyday.HolidayCalendar;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collection;
import java.util.Optional;
@Configuration
@ConfigurationProperties(prefix = "financer")
public class FinancerConfig {
private static final Logger LOGGER = LoggerFactory.getLogger(FinancerConfig.class);
private String countryCode;
private String state;
private String dateFormat;
private Collection<String> mailRecipients;
private String fromAddress;
/**
* @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<HolidayCalendar> optionalHoliday = Arrays.stream(HolidayCalendar.values())
.filter((hc) -> hc.getId().equals(this.countryCode))
.findFirst();
if (!optionalHoliday.isPresent()) {
LOGGER.warn(String
.format("Use Germany as fallback country for holiday calculations. Configured country code is: %s. " +
"This does not match any valid country code as specified by Jollyday",
this.countryCode));
}
return optionalHoliday.orElse(HolidayCalendar.GERMANY);
}
public void setState(String state) {
this.state = state;
}
public void setCountryCode(String countryCode) {
this.countryCode = countryCode;
}
/**
* @return the date format used in e.g. the {@link de.financer.service.TransactionService#createTransaction(String,
* String, Long, String, String) TransactionService#createTransaction} or {@link
* de.financer.service.RecurringTransactionService#createRecurringTransaction(String, String, Long, String, String,
* String, String, String) RecurringTransactionService#createRecurringTransaction} methods. Used to parse the
* client-supplied date string to proper {@link java.time.LocalDate LocalDate} objects
*/
public String getDateFormat() {
return dateFormat;
}
public void setDateFormat(String dateFormat) {
try {
DateTimeFormatter.ofPattern(dateFormat);
} catch (IllegalArgumentException e) {
LOGGER.warn(String
.format("Use 'dd.MM.yyyy' as fallback for the date format because the configured format '%s' " +
"cannot be parsed!", dateFormat), e);
dateFormat = "dd.MM.yyyy";
}
this.dateFormat = dateFormat;
}
/**
* @return a collection of email addresses that should receive mails from financer
*/
public Collection<String> getMailRecipients() {
return mailRecipients;
}
public void setMailRecipients(Collection<String> mailRecipients) {
this.mailRecipients = mailRecipients;
}
/**
* @return the from address used in emails send by financer
*/
public String getFromAddress() {
return fromAddress;
}
public void setFromAddress(String fromAddress) {
this.fromAddress = fromAddress;
}
}

View File

@@ -0,0 +1,89 @@
package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.model.Account;
import de.financer.service.AccountService;
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;
@RestController
@RequestMapping("accounts")
public class AccountController {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountController.class);
@Autowired
private AccountService accountService;
@RequestMapping("getByKey")
public Account getAccountByKey(String key) {
final String decoded = ControllerUtil.urlDecode(key);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/getByKey got parameter: %s", decoded));
}
return this.accountService.getAccountByKey(decoded);
}
@RequestMapping("getAll")
public Iterable<Account> getAll() {
return this.accountService.getAll();
}
@RequestMapping("createAccount")
public ResponseEntity createAccount(String key, String type, String accountGroupName) {
final String decoded = ControllerUtil.urlDecode(key);
final String decodedGroup = ControllerUtil.urlDecode(accountGroupName);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/createAccount got parameters: %s, %s, %s", decoded, type, decodedGroup));
}
final ResponseReason responseReason = this.accountService.createAccount(decoded, type, decodedGroup);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/createAccount returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("closeAccount")
public ResponseEntity closeAccount(String key) {
final String decoded = ControllerUtil.urlDecode(key);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/closeAccount got parameters: %s", decoded));
}
final ResponseReason responseReason = this.accountService.closeAccount(decoded);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/closeAccount returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("openAccount")
public ResponseEntity openAccount(String key) {
final String decoded = ControllerUtil.urlDecode(key);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/openAccount got parameters: %s", decoded));
}
final ResponseReason responseReason = this.accountService.openAccount(decoded);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/openAccount returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
}

View File

@@ -0,0 +1,53 @@
package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.model.AccountGroup;
import de.financer.service.AccountGroupService;
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;
@RestController
@RequestMapping("accountGroups")
public class AccountGroupController {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountGroupController.class);
@Autowired
private AccountGroupService accountGroupService;
@RequestMapping("getByName")
public AccountGroup getAccountGroupByName(String name) {
final String decoded = ControllerUtil.urlDecode(name);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accountGroups/getByName got parameter: %s", decoded));
}
return this.accountGroupService.getAccountGroupByName(decoded);
}
@RequestMapping("getAll")
public Iterable<AccountGroup> getAll() {
return this.accountGroupService.getAll();
}
@RequestMapping("createAccountGroup")
public ResponseEntity createAccountGroup(String name) {
final String decoded = ControllerUtil.urlDecode(name);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accountGroups/createAccountGroup got parameter: %s", decoded));
}
final ResponseReason responseReason = this.accountGroupService.createAccountGroup(decoded);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accountGroups/createAccountGroup returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
}

View File

@@ -0,0 +1,22 @@
package de.financer.controller;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
public class ControllerUtil {
/**
* This method decodes the given URL encoded string, e.g. replaces <code>%20</code> 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;
}
}
}

View File

@@ -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<RecurringTransaction> getAll() {
return this.recurringTransactionService.getAll();
}
@RequestMapping("getAllActive")
public Iterable<RecurringTransaction> getAllActive() {
return this.recurringTransactionService.getAllActive();
}
@RequestMapping("getAllForAccount")
public Iterable<RecurringTransaction> getAllForAccount(String accountKey) {
final String decoded = ControllerUtil.urlDecode(accountKey);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/recurringTransactions/getAllForAccount got parameter: %s", decoded));
}
return this.recurringTransactionService.getAllForAccount(decoded);
}
@RequestMapping("getAllDueToday")
public Iterable<RecurringTransaction> 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, Boolean remind
) {
final String decodedFrom = ControllerUtil.urlDecode(fromAccountKey);
final String decodedTo = ControllerUtil.urlDecode(toAccountKey);
final String decodedDesc = ControllerUtil.urlDecode(description);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/createRecurringTransaction got parameters: %s, %s, %s, %s, %s, " +
"%s, %s, %s, %s", decodedFrom, decodedTo, amount, decodedDesc, holidayWeekendType,
intervalType, firstOccurrence, lastOccurrence, remind));
}
final ResponseReason responseReason = this.recurringTransactionService
.createRecurringTransaction(decodedFrom, decodedTo, amount, decodedDesc, holidayWeekendType,
intervalType, firstOccurrence, lastOccurrence, remind);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/createRecurringTransaction returns with %s", responseReason
.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("createTransaction")
public ResponseEntity createTransaction(String recurringTransactionId, Long amount) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/createTransaction got parameters: %s, %s",
recurringTransactionId, amount));
}
final ResponseReason responseReason = this.recurringTransactionService
.createTransaction(recurringTransactionId, Optional.ofNullable(amount));
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/createTransaction returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("deleteRecurringTransaction")
public ResponseEntity deleteRecurringTransaction(String recurringTransactionId) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/deleteRecurringTransaction got parameters: %s",
recurringTransactionId));
}
final ResponseReason responseReason = this.recurringTransactionService
.deleteRecurringTransaction(recurringTransactionId);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/recurringTransactions/deleteRecurringTransaction returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
}

View File

@@ -0,0 +1,82 @@
package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.model.Transaction;
import de.financer.service.TransactionService;
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;
@RestController
@RequestMapping("transactions")
public class TransactionController {
private static final Logger LOGGER = LoggerFactory.getLogger(TransactionController.class);
@Autowired
private TransactionService transactionService;
@RequestMapping("getAll")
public Iterable<Transaction> getAll() {
return this.transactionService.getAll();
}
@RequestMapping("getAllForAccount")
public Iterable<Transaction> getAllForAccount(String accountKey) {
final String decoded = ControllerUtil.urlDecode(accountKey);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getAllForAccount got parameter: %s", decoded));
}
return this.transactionService.getAllForAccount(decoded);
}
@RequestMapping(value = "createTransaction")
public ResponseEntity createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description
) {
final String decodedFrom = ControllerUtil.urlDecode(fromAccountKey);
final String decodedTo = ControllerUtil.urlDecode(toAccountKey);
final String decodedDesc = ControllerUtil.urlDecode(description);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/createTransaction got parameters: %s, %s, %s, %s, %s",
decodedFrom, decodedTo, amount, date, decodedDesc));
}
final ResponseReason responseReason = this.transactionService
.createTransaction(decodedFrom, decodedTo, amount, date, decodedDesc);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/createTransaction returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("deleteTransaction")
public ResponseEntity deleteTransaction(String transactionId) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction got parameters: %s",
transactionId));
}
final ResponseReason responseReason = this.transactionService
.deleteTransaction(transactionId);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
}

View File

@@ -0,0 +1,11 @@
package de.financer.dba;
import de.financer.model.AccountGroup;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Transactional(propagation = Propagation.REQUIRED)
public interface AccountGroupRepository extends CrudRepository<AccountGroup, Long> {
AccountGroup findByName(String name);
}

View File

@@ -0,0 +1,11 @@
package de.financer.dba;
import de.financer.model.Account;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Transactional(propagation = Propagation.REQUIRED)
public interface AccountRepository extends CrudRepository<Account, Long> {
Account findByKey(String key);
}

View File

@@ -0,0 +1,21 @@
package de.financer.dba;
import de.financer.model.Account;
import de.financer.model.RecurringTransaction;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
@Transactional(propagation = Propagation.REQUIRED)
public interface RecurringTransactionRepository extends CrudRepository<RecurringTransaction, Long> {
Iterable<RecurringTransaction> findRecurringTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);
@Query("SELECT rt FROM RecurringTransaction rt WHERE rt.deleted = false AND (rt.lastOccurrence IS NULL OR rt.lastOccurrence >= :lastOccurrence)")
Iterable<RecurringTransaction> findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate lastOccurrence);
Iterable<RecurringTransaction> findByDeletedFalse();
}

View File

@@ -0,0 +1,12 @@
package de.financer.dba;
import de.financer.model.Account;
import de.financer.model.Transaction;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Transactional(propagation = Propagation.REQUIRED)
public interface TransactionRepository extends CrudRepository<Transaction, Long> {
Iterable<Transaction> findTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);
}

View File

@@ -0,0 +1,63 @@
package de.financer.model;
import javax.persistence.*;
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "\"key\"") // we need to escape the keyword "key"
private String key;
@Enumerated(EnumType.STRING)
private AccountType type;
@Enumerated(EnumType.STRING)
private AccountStatus status;
private Long currentBalance;
@ManyToOne
private AccountGroup accountGroup;
public Long getId() {
return id;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public AccountType getType() {
return type;
}
public void setType(AccountType type) {
this.type = type;
}
public AccountStatus getStatus() {
return status;
}
public void setStatus(AccountStatus status) {
this.status = status;
}
public Long getCurrentBalance() {
return currentBalance;
}
public void setCurrentBalance(Long currentBalance) {
this.currentBalance = currentBalance;
}
public AccountGroup getAccountGroup() {
return accountGroup;
}
public void setAccountGroup(AccountGroup accountGroup) {
this.accountGroup = accountGroup;
}
}

View File

@@ -0,0 +1,23 @@
package de.financer.model;
import javax.persistence.*;
@Entity
public class AccountGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,20 @@
package de.financer.model;
import java.util.Arrays;
public enum AccountStatus {
/** Indicates that the account is open for bookings */
OPEN,
/** Indicates that the account is closed and bookings to it are forbidden */
CLOSED;
/**
* This method validates whether the given string represents a valid account status.
*
* @param status to check
* @return whether the given status represents a valid account status
*/
public static boolean isValidType(String status) {
return Arrays.stream(AccountStatus.values()).anyMatch((accountStatus) -> accountStatus.name().equals(status));
}
}

View File

@@ -0,0 +1,33 @@
package de.financer.model;
import java.util.*;
public enum AccountType {
/** Used to mark an account that acts as a source of money, e.g. monthly wage */
INCOME,
/** Indicates a real account at a bank, e.g. a check payment account */
BANK,
/** Marks an account as physical cash, e.g. the money currently in the purse */
CASH,
/** Used to mark an account that acts as a destination of money, e.g. through buying goods */
EXPENSE,
/** Marks an account as a liability from a third party, e.g. credit card or loan */
LIABILITY,
/** Marks the start account that is to be used to book all the opening balances for the different accounts */
START;
/**
* This method validates whether the given string represents a valid account type.
*
* @param type to check
* @return whether the given type represents a valid account type
*/
public static boolean isValidType(String type) {
return Arrays.stream(AccountType.values()).anyMatch((accountType) -> accountType.name().equals(type));
}
}

View File

@@ -0,0 +1,65 @@
package de.financer.model;
import java.util.Arrays;
/**
* This enum specifies constants that control how actions should be handled that would fall on a holiday
* or weekday (where usually are no bookings done by e.g. banks)
*/
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,
/**
* <p>
* Indicates that the action should be deferred to the next workday.
* </p>
* <pre>
* Example 1:
* MO TU WE TH FR SA SO
* H WE WE -&gt; Holiday/WeekEnd
* X -&gt; Due date of action
* X' -&gt; Deferred, effective due date of action
* </pre>
* <pre>
* Example 2:
* TU WE TH FR SA SO MO
* H WE WE -&gt; Holiday/WeekEnd
* X -&gt; Due date of action
* X' -&gt; Deferred, effective due date of action
* </pre>
*
*/
NEXT_WORKDAY,
/**
* <p>
* Indicates that the action should preponed to the previous day
* </p>
* <pre>
* Example 1:
* MO TU WE TH FR SA SO
* H WE WE -&gt; Holiday/WeekEnd
* X -&gt; Due date of action
* X' -&gt; Earlier, effective due date of action
* </pre>
* <pre>
* Example 2:
* MO TU WE TH FR SA SO
* H WE WE -&gt; Holiday/WeekEnd
* X -&gt; Due date of action
* X' -&gt; Earlier, effective due date of action
* </pre>
*/
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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,10 @@
/**
* <p>
* This package contains the main model for the financer application.
* In the DDD (<i>Domain Driven Design</i>) 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.
* </p>
*/
package de.financer.model;

View File

@@ -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 Iterable<AccountGroup> getAll() {
return this.accountGroupRepository.findAll();
}
/**
* This method returns the account group with the given name.
*
* @param name the name to get the account group for
* @return the account group or <code>null</code> 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 <code>null</code>.
*/
@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;
}
}

View File

@@ -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 <code>null</code> 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 Iterable<Account> getAll() {
return this.accountRepository.findAll();
}
/**
* This method saves the given account. It either updates the account if it already exists or inserts
* it if it's new.
*
* @param account the account to save
*/
@Transactional(propagation = Propagation.REQUIRED)
public void saveAccount(Account account) {
this.accountRepository.save(account);
}
/**
* This method creates new account with the given key and type. The account has status {@link AccountStatus#OPEN OPEN}
* and a current balance of <code>0</code>.
*
* @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 <code>null</code>
* @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
* <code>accountGroupName</code> does not identify a valid account group. Never returns <code>null</code>.
*/
@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;
}
}

View File

@@ -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 Iterable<RecurringTransaction> getAll() {
return this.recurringTransactionRepository.findByDeletedFalse();
}
public Iterable<RecurringTransaction> getAllActive() {
return this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate.now());
}
public Iterable<RecurringTransaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(accountKey);
if (account == null) {
LOGGER.warn(String.format("Account with key %s not found!", accountKey));
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<RecurringTransaction> getAllDueToday() {
return this.getAllDueToday(LocalDate.now());
}
// Visible for unit tests
/* package */ Iterable<RecurringTransaction> getAllDueToday(LocalDate now) {
// Subtract one week/seven days from the current date so that recurring transactions that have their last
// occurrence on a weekend or a holiday in the near past and HWT NEXT_WORKDAY are also grabbed. Otherwise
// there would never be a reminder about them. On the actual due date the reminder is deferred because of the
// HWT and for later runs it's not grabbed because of the condition '...LastOccurrenceGreaterThanEqual(now)'
final Iterable<RecurringTransaction> allRecurringTransactions = this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(now.minusDays(7));
LOGGER.debug(String.format("Found %s candidate recurring transactions. Checking which are due",
IterableUtils.size(allRecurringTransactions)));
//@formatter:off
return IterableUtils.toList(allRecurringTransactions).stream()
.filter((rt) -> checkRecurringTransactionDueToday(rt, now) ||
checkRecurringTransactionDuePast(rt, now) ||
checkRecurringTransactionDueFuture(rt, now))
.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 <code>true</code> if the recurring transaction is due today, <code>false</code> 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 <i>maybe</i> 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 <code>true</code> if the recurring transaction is due today, <code>false</code> 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 <i>maybe</i> 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 <code>true</code> if the recurring transaction is due today, <code>false</code> 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 <code>null</code>
* @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 <code>null</code>
*
* @return the first error found or <code>null</code> 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<Long> amount) {
if (recurringTransactionId == null) {
return ResponseReason.MISSING_RECURRING_TRANSACTION_ID;
} else if (!NumberUtils.isCreatable(recurringTransactionId)) {
return ResponseReason.INVALID_RECURRING_TRANSACTION_ID;
}
final Optional<RecurringTransaction> optionalRecurringTransaction = this.recurringTransactionRepository
.findById(Long.valueOf(recurringTransactionId));
if (!optionalRecurringTransaction.isPresent()) {
return ResponseReason.RECURRING_TRANSACTION_NOT_FOUND;
}
final RecurringTransaction recurringTransaction = optionalRecurringTransaction.get();
return this.transactionService.createTransaction(recurringTransaction.getFromAccount().getKey(),
recurringTransaction.getToAccount().getKey(),
amount.orElseGet(recurringTransaction::getAmount),
LocalDate.now().format(DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())),
recurringTransaction.getDescription(),
recurringTransaction);
}
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason deleteRecurringTransaction(String recurringTransactionId) {
ResponseReason response = ResponseReason.OK;
if (recurringTransactionId == null) {
return ResponseReason.MISSING_RECURRING_TRANSACTION_ID;
} else if (!NumberUtils.isCreatable(recurringTransactionId)) {
return ResponseReason.INVALID_RECURRING_TRANSACTION_ID;
}
final Optional<RecurringTransaction> optionalRecurringTransaction = this.recurringTransactionRepository
.findById(Long.valueOf(recurringTransactionId));
if (!optionalRecurringTransaction.isPresent()) {
return ResponseReason.RECURRING_TRANSACTION_NOT_FOUND;
}
try {
RecurringTransaction recurringTransaction = optionalRecurringTransaction.get();
recurringTransaction.setDeleted(true);
this.recurringTransactionRepository.save(recurringTransaction);
} catch (Exception e) {
LOGGER.error("Could not delete recurring transaction!", e);
response = ResponseReason.UNKNOWN_ERROR;
}
return response;
}
}

View File

@@ -0,0 +1,186 @@
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 basic logic rules. While most of the logic could be placed elsewhere this
* service provides centralized access to these rules. Placing them in here also enables easy unit testing.
*/
@Service
public class RuleService implements InitializingBean {
private static final Logger LOGGER = LoggerFactory.getLogger(RuleService.class);
@Autowired
private FinancerConfig financerConfig;
private Map<AccountType, Collection<AccountType>> bookingRules;
private Map<IntervalType, Period> 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() {
this.bookingRules = new EnumMap<>(AccountType.class);
// This map contains valid booking constellations
// The key is the from account and the value is a list of valid
// to accounts for this from account
this.bookingRules.put(INCOME, Arrays.asList(BANK, CASH));
this.bookingRules.put(BANK, Arrays.asList(BANK, CASH, EXPENSE, LIABILITY));
this.bookingRules.put(CASH, Arrays.asList(BANK, EXPENSE, LIABILITY));
this.bookingRules.put(EXPENSE, Collections.emptyList());
this.bookingRules.put(LIABILITY, Arrays.asList(BANK, CASH, EXPENSE));
this.bookingRules.put(START, Arrays.asList(BANK, CASH, LIABILITY));
}
/**
* This method returns the multiplier for the given from account.
* <p>
* 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 <code>1</code> or <code>-1</code>
*/
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.
* <p>
* 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 <code>1</code> or <code>-1</code>
*/
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 <code>fromAccount</code> to <code>toAccount</code> 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 <code>true</code> if the from-&gt;to relationship of the given accounts is valid, <code>false</code>
* 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 <code>true</code> if the given date is a holiday, <code>false</code> 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 <code>true</code> if the given date is a weekend day, <code>false</code> otherwise
*/
public boolean isWeekend(LocalDate now) {
return EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(now.getDayOfWeek());
}
}

View File

@@ -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 Iterable<Transaction> getAll() {
return this.transactionRepository.findAll();
}
/**
* @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.
*/
public Iterable<Transaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(accountKey);
if (account == null) {
LOGGER.warn(String.format("Account with key %s not found!", accountKey));
return Collections.emptyList();
}
// As we want all transactions of the given account use it as from and to account
return this.transactionRepository.findTransactionsByFromAccountOrToAccount(account, account);
}
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description)
{
return this.createTransaction(fromAccountKey, toAccountKey, amount, date, description, null);
}
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description, RecurringTransaction recurringTransaction
) {
final Account fromAccount = this.accountService.getAccountByKey(fromAccountKey);
final Account toAccount = this.accountService.getAccountByKey(toAccountKey);
ResponseReason response = validateParameters(fromAccount, toAccount, amount, date);
// 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 Transaction transaction = buildTransaction(fromAccount, toAccount, amount, description, date, recurringTransaction);
fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService
.getMultiplierFromAccount(fromAccount) * amount));
// Special case: if we do the initial bookings, and the booking is to introduce a liability,
// the balance of the liability account must increase
if (AccountType.START.equals(fromAccount.getType()) && AccountType.LIABILITY.equals(toAccount.getType())) {
toAccount.setCurrentBalance(toAccount.getCurrentBalance() + (this.ruleService
.getMultiplierToAccount(toAccount) * amount * -1));
}
else {
toAccount.setCurrentBalance(toAccount.getCurrentBalance() + (this.ruleService
.getMultiplierToAccount(toAccount) * amount));
}
this.transactionRepository.save(transaction);
this.accountService.saveAccount(fromAccount);
this.accountService.saveAccount(toAccount);
response = ResponseReason.OK;
} catch (Exception e) {
LOGGER.error("Could not create transaction!", e);
response = ResponseReason.UNKNOWN_ERROR;
}
return response;
}
/**
* 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 description the description of the transaction
* @param date the date of the transaction
* @param recurringTransaction the recurring transaction that caused the creation of this transaction, may be
* <code>null</code>
*
* @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 <code>null</code> 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<Transaction> optionalTransaction = this.transactionRepository
.findById(Long.valueOf(transactionId));
if (!optionalTransaction.isPresent()) {
return ResponseReason.TRANSACTION_NOT_FOUND;
}
final Transaction transaction = optionalTransaction.get();
final Account fromAccount = transaction.getFromAccount();
final Account toAccount = transaction.getToAccount();
final Long amount = transaction.getAmount();
// Invert the actual multiplier by multiplying with -1
// If we delete a transaction we do the inverse of the original transaction
fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService
.getMultiplierFromAccount(fromAccount) * amount * -1));
toAccount.setCurrentBalance(toAccount.getCurrentBalance() + (this.ruleService
.getMultiplierToAccount(toAccount) * amount * -1));
try {
this.transactionRepository.deleteById(Long.valueOf(transactionId));
this.accountService.saveAccount(fromAccount);
this.accountService.saveAccount(toAccount);
}
catch (Exception e) {
LOGGER.error("Could not delete transaction!", e);
response = ResponseReason.UNKNOWN_ERROR;
}
return response;
}
}

View File

@@ -0,0 +1,9 @@
/**
* <p>
* 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.
* </p>
*/
package de.financer.service;

View File

@@ -0,0 +1,94 @@
package de.financer.task;
import de.financer.config.FinancerConfig;
import de.financer.model.RecurringTransaction;
import de.financer.service.RecurringTransactionService;
import org.apache.commons.collections4.IterableUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
@Component
public class SendRecurringTransactionReminderTask {
private static final Logger LOGGER = LoggerFactory.getLogger(SendRecurringTransactionReminderTask.class);
@Autowired
private RecurringTransactionService recurringTransactionService;
@Autowired
private FinancerConfig financerConfig;
@Autowired
private JavaMailSender mailSender;
@Scheduled(cron = "0 30 0 * * *")
public void sendReminder() {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Enter recurring transaction reminder task");
}
Iterable<RecurringTransaction> recurringTransactions = this.recurringTransactionService.getAllDueToday();
// If no recurring transaction is due today we don't need to send a reminder
if (IterableUtils.isEmpty(recurringTransactions)) {
LOGGER.info("No recurring transactions due today!");
return; // early return
}
// TODO Filtering currently happens in memory but should be done via SQL
recurringTransactions = IterableUtils.toList(recurringTransactions)
.stream()
.filter((rt) -> rt.isRemind())
.collect(Collectors.toList());
LOGGER.info(String
.format("%s recurring transaction are due today and are about to be included in the reminder email",
IterableUtils.size(recurringTransactions)));
final StringBuilder reminderBuilder = new StringBuilder();
reminderBuilder.append("The following recurring transactions are due today:")
.append(System.lineSeparator())
.append(System.lineSeparator());
IterableUtils.toList(recurringTransactions).forEach((rt) -> {
reminderBuilder.append(rt.getId())
.append("|")
.append(rt.getDescription())
.append(System.lineSeparator())
.append("From ")
.append(rt.getFromAccount().getKey())
.append(" to ")
.append(rt.getToAccount().getKey())
.append(": ")
.append(rt.getAmount().toString())
.append(System.lineSeparator())
.append(System.lineSeparator());
});
final SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(this.financerConfig.getMailRecipients().toArray(new String[]{}));
msg.setFrom(this.financerConfig.getFromAddress());
msg.setSubject("[Financer] Recurring transactions reminder");
msg.setText(reminderBuilder.toString());
try {
this.mailSender.send(msg);
} catch (MailException e) {
LOGGER.error("Could not send recurring transaction email reminder!", e);
LOGGER.info("Dumb email reminder content because the sending failed");
LOGGER.info(reminderBuilder.toString());
}
}
}

View File

@@ -0,0 +1,2 @@
# Hibernate
spring.jpa.show-sql=true

View File

@@ -0,0 +1,6 @@
spring.flyway.locations=classpath:/database/hsqldb,classpath:/database/common
# DataSource
#spring.datasource.url=jdbc:hsqldb:file:/tmp/financer
spring.datasource.url=jdbc:hsqldb:mem:.
spring.datasource.username=sa

View File

@@ -0,0 +1,8 @@
spring.flyway.locations=classpath:/database/postgres,classpath:/database/common
spring.datasource.url=jdbc:postgresql://localhost/financer
spring.datasource.username=financer
spring.datasource.password=financer
# See https://github.com/spring-projects/spring-boot/issues/12007
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true

View File

@@ -0,0 +1,49 @@
###
### This is the main configuration file of the application.
### Filtering of the @...@ values happens via the maven-resource-plugin. The execution of the plugin is configured in
### the Spring Boot parent POM.
spring.profiles.active=@activeProfiles@
server.servlet.context-path=/financer-server
server.port=8089
spring.jpa.hibernate.ddl-auto=validate
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@
logging.level.de.financer=DEBUG
logging.file=financer-server.log
logging.file.max-history=7
logging.file.max-size=50MB
# 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
# The date format of the client-supplied date string, used to parse the string into a proper object
financer.dateFormat=dd.MM.yyyy
# A collection of email addresses that should receive mails from financer
financer.mailRecipients[0]=marius@kleberonline.de
# The from address used in emails send by financer
financer.fromAddress=financer@77zzcx7.de
# Mail configuration
spring.mail.host=localhost
#spring.mail.username=
#spring.mail.password=
# Disable JMX as we don't need it and it blocks parallel deployment on Tomcat
# because the connection pool cannot shutdown properly
spring.jmx.enabled=false

View File

@@ -0,0 +1,66 @@
-- Accounts
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);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.rent', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.fvs', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.car', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.gas', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.alimony', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.electricitywater', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.mobile', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.internet', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.legalinsurance', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.netflix', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.hetzner', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.fees', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.food', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.foodexternal', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.child', 'EXPENSE', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.creditcard', 'LIABILITY', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.studentloan', 'LIABILITY', 'OPEN', 0);
INSERT INTO account ("key", type, status, current_balance)
VALUES ('accounts.bed', 'LIABILITY', 'OPEN', 0);

View File

@@ -0,0 +1,88 @@
-- Rename all accounts to proper names instead of the artificial 'accounts.' names
UPDATE account
SET "key" = 'Check account'
WHERE "key" = 'accounts.checkaccount';
UPDATE account
SET "key" = 'Income'
WHERE "key" = 'accounts.income';
UPDATE account
SET "key" = 'Cash'
WHERE "key" = 'accounts.cash';
UPDATE account
SET "key" = 'Start'
WHERE "key" = 'accounts.start';
UPDATE account
SET "key" = 'Rent'
WHERE "key" = 'accounts.rent';
UPDATE account
SET "key" = 'FVS'
WHERE "key" = 'accounts.fvs';
UPDATE account
SET "key" = 'Car'
WHERE "key" = 'accounts.car';
UPDATE account
SET "key" = 'Gas'
WHERE "key" = 'accounts.gas';
UPDATE account
SET "key" = 'Alimony'
WHERE "key" = 'accounts.alimony';
UPDATE account
SET "key" = 'Electricity/Water'
WHERE "key" = 'accounts.electricitywater';
UPDATE account
SET "key" = 'Mobile'
WHERE "key" = 'accounts.mobile';
UPDATE account
SET "key" = 'Internet'
WHERE "key" = 'accounts.internet';
UPDATE account
SET "key" = 'Legal insurance'
WHERE "key" = 'accounts.legalinsurance';
UPDATE account
SET "key" = 'Netflix'
WHERE "key" = 'accounts.netflix';
UPDATE account
SET "key" = 'Hetzner'
WHERE "key" = 'accounts.hetzner';
UPDATE account
SET "key" = 'Fees'
WHERE "key" = 'accounts.fees';
UPDATE account
SET "key" = 'Food'
WHERE "key" = 'accounts.food';
UPDATE account
SET "key" = 'Food (external)'
WHERE "key" = 'accounts.foodexternal';
UPDATE account
SET "key" = 'Child'
WHERE "key" = 'accounts.child';
UPDATE account
SET "key" = 'Credit card'
WHERE "key" = 'accounts.creditcard';
UPDATE account
SET "key" = 'Student loan'
WHERE "key" = 'accounts.studentloan';
UPDATE account
SET "key" = 'Bed'
WHERE "key" = 'accounts.bed';

View File

@@ -0,0 +1,17 @@
INSERT INTO account_group (name)
VALUES ('Miscellaneous');
INSERT INTO account_group (name)
VALUES ('Car');
INSERT INTO account_group (name)
VALUES ('Housing');
INSERT INTO account_group (name)
VALUES ('Child');
INSERT INTO account_group (name)
VALUES ('Insurance');
INSERT INTO account_group (name)
VALUES ('Entertainment');

View File

@@ -0,0 +1,46 @@
--
-- This file contains the basic initialization of the financer schema and basic init data
--
-- Account table
CREATE TABLE account (
id BIGINT NOT NULL PRIMARY KEY IDENTITY,
"key" VARCHAR(1000) NOT NULL, --escape keyword "key"
type VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
current_balance BIGINT NOT NULL,
CONSTRAINT un_account_name_key UNIQUE ("key")
);
-- Recurring transaction table
CREATE TABLE recurring_transaction (
id BIGINT NOT NULL PRIMARY KEY IDENTITY,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
description VARCHAR(1000),
amount BIGINT NOT NULL,
interval_type VARCHAR(255) NOT NULL,
first_occurrence DATE NOT NULL,
last_occurrence DATE,
holiday_weekend_type VARCHAR(255) NOT NULL,
deleted BOOLEAN DEFAULT FALSE NOT NULL,
CONSTRAINT fk_recurring_transaction_from_account FOREIGN KEY (from_account_id) REFERENCES account (id),
CONSTRAINT fk_recurring_transaction_to_account FOREIGN KEY (to_account_id) REFERENCES account (id)
);
-- Transaction table
CREATE TABLE "transaction" ( --escape keyword "transaction"
id BIGINT NOT NULL PRIMARY KEY IDENTITY,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
"date" DATE NOT NULL, --escape keyword "date"
description VARCHAR(1000),
amount BIGINT NOT NULL,
recurring_transaction_id BIGINT,
CONSTRAINT fk_transaction_from_account FOREIGN KEY (from_account_id) REFERENCES account (id),
CONSTRAINT fk_transaction_to_account FOREIGN KEY (to_account_id) REFERENCES account (id),
CONSTRAINT fk_transaction_recurring_transaction FOREIGN KEY (recurring_transaction_id) REFERENCES recurring_transaction (id)
);

View File

@@ -0,0 +1,4 @@
-- Add a new column to the recurring transaction table that controls whether
-- a reminder about the maturity should be send
ALTER TABLE recurring_transaction
ADD COLUMN remind BOOLEAN DEFAULT TRUE NOT NULL;

View File

@@ -0,0 +1,16 @@
-- Account group table
CREATE TABLE account_group (
id BIGINT NOT NULL PRIMARY KEY IDENTITY,
name VARCHAR(1000) NOT NULL,
CONSTRAINT un_account_group_name_key UNIQUE (name)
);
-- Add a column for the Account group
ALTER TABLE account
ADD COLUMN account_group_id BIGINT;
-- Add a foreign key column to the Account table referencing an Account group
ALTER TABLE account
ADD CONSTRAINT fk_account_account_group
FOREIGN KEY (account_group_id) REFERENCES account_group (id);

View File

@@ -0,0 +1,46 @@
--
-- This file contains the basic initialization of the financer schema and basic init data
--
-- Account table
CREATE TABLE account (
id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
"key" VARCHAR(1000) NOT NULL, --escape keyword "key"
type VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
current_balance BIGINT NOT NULL,
CONSTRAINT un_account_name_key UNIQUE ("key")
);
-- Recurring transaction table
CREATE TABLE recurring_transaction (
id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
description VARCHAR(1000),
amount BIGINT NOT NULL,
interval_type VARCHAR(255) NOT NULL,
first_occurrence DATE NOT NULL,
last_occurrence DATE,
holiday_weekend_type VARCHAR(255) NOT NULL,
deleted BOOLEAN DEFAULT 'TRUE' NOT NULL,
CONSTRAINT fk_recurring_transaction_from_account FOREIGN KEY (from_account_id) REFERENCES account (id),
CONSTRAINT fk_recurring_transaction_to_account FOREIGN KEY (to_account_id) REFERENCES account (id)
);
-- Transaction table
CREATE TABLE "transaction" ( --escape keyword "transaction"
id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
"date" DATE NOT NULL, --escape keyword "date"
description VARCHAR(1000),
amount BIGINT NOT NULL,
recurring_transaction_id BIGINT,
CONSTRAINT fk_transaction_from_account FOREIGN KEY (from_account_id) REFERENCES account (id),
CONSTRAINT fk_transaction_to_account FOREIGN KEY (to_account_id) REFERENCES account (id),
CONSTRAINT fk_transaction_recurring_transaction FOREIGN KEY (recurring_transaction_id) REFERENCES recurring_transaction (id)
);

View File

@@ -0,0 +1,4 @@
-- Add a new column to the recurring transaction table that controls whether
-- a reminder about the maturity should be send
ALTER TABLE recurring_transaction
ADD COLUMN remind BOOLEAN DEFAULT 'TRUE' NOT NULL

View File

@@ -0,0 +1,16 @@
-- Account group table
CREATE TABLE account_group (
id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name VARCHAR(1000) NOT NULL,
CONSTRAINT un_account_group_name_key UNIQUE (name)
);
-- Add a column for the Account group
ALTER TABLE account
ADD COLUMN account_group_id BIGINT;
-- Add a foreign key column to the Account table referencing an Account group
ALTER TABLE account
ADD CONSTRAINT fk_account_account_group
FOREIGN KEY (account_group_id) REFERENCES account_group (id);

View File

@@ -0,0 +1,25 @@
The recurring transaction table is defined like this (at least for postgres):
-- Recurring transaction table
CREATE TABLE recurring_transaction (
id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
description VARCHAR(1000),
amount BIGINT NOT NULL,
interval_type VARCHAR(255) NOT NULL,
first_occurrence DATE NOT NULL,
last_occurrence DATE,
holiday_weekend_type VARCHAR(255) NOT NULL,
deleted BOOLEAN DEFAULT 'TRUE' NOT NULL,
CONSTRAINT fk_recurring_transaction_from_account FOREIGN KEY (from_account_id) REFERENCES account (id),
CONSTRAINT fk_recurring_transaction_to_account FOREIGN KEY (to_account_id) REFERENCES account (id)
);
Note the
deleted BOOLEAN DEFAULT 'TRUE' NOT NULL,
column definition. Not sure why the default is TRUE here is it doesn't make sense.
It was probably a mistake, however fixing it here _WILL_ break existing installations
as Flyway uses a checksum for scripts. So there is no easy fix, except for effectively
overwriting this default in Java code when creating a new recurring transaction.
See RecurringTransactionService.createRecurringTransaction()

View File

@@ -0,0 +1,31 @@
package de.financer;
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.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = FinancerApplication.class)
@AutoConfigureMockMvc
@TestPropertySource(
locations = "classpath:application-integrationtest.properties")
public class FinancerApplicationBootTest {
@Autowired
private MockMvc mockMvc;
@Test
public void test_appBoots() {
// Nothing to do in this test as we just want to startup the app
// to make sure that spring, flyway and hibernate all work
// as expected even after changes
// While this slightly increases build time it's an easy and safe
// way to ensure that the app can start
Assert.assertTrue(true);
}
}

View File

@@ -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.Account;
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 AccountController_getAllIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@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 List<Account> allAccounts = this.objectMapper
.readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference<List<Account>>() {});
Assert.assertEquals(23, allAccounts.size());
}
}

View File

@@ -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<RecurringTransaction> allRecurringTransaction = this.objectMapper
.readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference<List<RecurringTransaction>>() {});
Assert.assertEquals(4, allRecurringTransaction.size());
}
}

View File

@@ -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<RecurringTransaction> allRecurringTransactions = this.objectMapper
.readValue(mvcResultAll.getResponse().getContentAsByteArray(), new TypeReference<List<RecurringTransaction>>() {});
final Optional<RecurringTransaction> optionalRecurringTransaction = allRecurringTransactions.stream().findFirst();
if (!optionalRecurringTransaction.isPresent()) {
Assert.fail("No recurring transaction found!");
}
this.mockMvc.perform(get("/recurringTransactions/createTransaction")
.param("recurringTransactionId", optionalRecurringTransaction.get().getId().toString()))
.andExpect(status().isOk())
.andReturn();
final MvcResult mvcResultAllTransactions = this.mockMvc.perform(get("/transactions/getAll")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
final List<Transaction> allTransactions = this.objectMapper
.readValue(mvcResultAllTransactions.getResponse().getContentAsByteArray(), new TypeReference<List<Transaction>>() {});
Assert.assertEquals(1, allTransactions.size());
}
}

View File

@@ -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<RecurringTransaction> allRecurringTransactions = this.objectMapper
.readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference<List<RecurringTransaction>>() {});
Assert.assertEquals(3, allRecurringTransactions.size());
}
}

View File

@@ -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<RecurringTransaction> allRecurringTransactions = this.objectMapper
.readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference<List<RecurringTransaction>>() {});
Assert.assertEquals(4, allRecurringTransactions.size());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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())));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 <b>not</b> get executed multiple times on the next workday. While this is
* somehow unfortunate it is <b>not</b> 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<RecurringTransaction> 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 <b>not</b> due on a holiday, non-weekend day
*/
@Test
public void test_getAllDueToday_dueToday_holiday() {
// Arrange
// Implicitly: ruleService.isWeekend().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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<RecurringTransaction> 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 <b>not</b> due on a non-holiday, weekend day
*/
@Test
public void test_getAllDueToday_dueToday_weekend() {
// Arrange
// Implicitly: ruleService.isHoliday().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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<RecurringTransaction> 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 <b>not</b> due today
*/
@Test
public void test_getAllDueToday_dueToday_tomorrow() {
// Arrange
// Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(1)));
final LocalDate now = LocalDate.now();
// Act
final Iterable<RecurringTransaction> 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);
recurringTransaction.setDeleted(false);
return recurringTransaction;
}
}

View File

@@ -0,0 +1,232 @@
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.Ignore;
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
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-1)));
// Today is not a holiday but yesterday was
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.TRUE);
final LocalDate now = LocalDate.now();
// Act
final Iterable<RecurringTransaction> 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
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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.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.FALSE, Boolean.TRUE);
// Act
final Iterable<RecurringTransaction> 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
@Ignore
// This test does not work as expected: if go back to the last sunday and then again one month back, we do
// not necessarily end up on on a date that causes the transaction to be due on monday
// e.g. 01.04.19 -> monday, 31.03.19 -> sunday, minus one month -> 28.02.19
// whereas the resulting 28.02.19 would be the first occurrence of the transaction. The next due dates would
// be 28.03.19 and 28.04.19 and not the 01.04.19 as expected
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
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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<RecurringTransaction> 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
@Ignore
// Same as with the _sunday test -> does not work as expected
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
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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<RecurringTransaction> recurringDueToday = this.classUnderTest.getAllDueToday(monday);
// Assert
Assert.assertEquals(1, IterableUtils.size(recurringDueToday));
}
/**
* This method tests whether a recurring transaction with firstOccurrence yesterday (a saturday) is <b>not</b> due
* today (a sunday).
*
* relates to: test_getAllDueToday_duePast_weekend_sunday
*/
@Test
public void test_getAllDueToday_duePast_weekend_not_due_on_sunday() {
// Arrange
final LocalDate now = LocalDate.of(2019, 5, 19); // A sunday
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(LocalDate.of(2019, 5, 18))));
// 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<RecurringTransaction> recurringDueToday = this.classUnderTest.getAllDueToday(now);
// Assert
Assert.assertEquals(0, IterableUtils.size(recurringDueToday));
}
@Test
public void test_() {
// Arrange
final LocalDate now = LocalDate.of(2019, 6, 17); // A monday
final RecurringTransaction recurringTransaction = new RecurringTransaction();
recurringTransaction.setLastOccurrence(LocalDate.of(2019, 6, 15)); // a saturday
recurringTransaction.setFirstOccurrence(LocalDate.of(2019, 5, 15)); // a wednesday
recurringTransaction.setHolidayWeekendType(HolidayWeekendType.NEXT_WORKDAY);
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(recurringTransaction));
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
.thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE);
Mockito.when(this.ruleService.isHoliday(Mockito.any()))
.thenReturn(Boolean.FALSE);
// Act
final Iterable<RecurringTransaction> recurringDueToday = this.classUnderTest.getAllDueToday(now);
// 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);
recurringTransaction.setDeleted(false);
return recurringTransaction;
}
private RecurringTransaction createRecurringTransaction(LocalDate firstOccurrence) {
final RecurringTransaction recurringTransaction = new RecurringTransaction();
recurringTransaction.setFirstOccurrence(firstOccurrence);
recurringTransaction.setHolidayWeekendType(HolidayWeekendType.NEXT_WORKDAY);
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
recurringTransaction.setDeleted(false);
return recurringTransaction;
}
}

View File

@@ -0,0 +1,97 @@
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_PREVIOUS_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 plus one day (and thus will
* actually be due tomorrow), intervalType = monthly and holidayWeekendType = previous_workday is due today, if
* tomorrow will be a holiday but today is not
*/
@Test
public void test_getAllDueToday_dueFuture_holiday() {
// Arrange
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(1)));
// Today is not a holiday but tomorrow is
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.TRUE);
final LocalDate now = LocalDate.now();
// Act
final Iterable<RecurringTransaction> recurringDueToday = this.classUnderTest.getAllDueToday(now);
// Assert
Assert.assertEquals(1, IterableUtils.size(recurringDueToday));
}
/**
* Negative test case for the following: recurringTransaction firstOccurrence = saturday the 15th,
* intervalType = monthly and holidayWeekendType = previous_workday => should not be due today if today is the 15th,
* as it was actually due yesterday.
*/
@Test
public void test_getAllDueToday_PreviousWorkday_weekend_notDue() {
// Arrange
final RecurringTransaction recurringTransaction = new RecurringTransaction();
recurringTransaction.setFirstOccurrence(LocalDate.of(2019, 6, 15));
recurringTransaction.setHolidayWeekendType(HolidayWeekendType.PREVIOUS_WORKDAY);
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.thenReturn(Collections.singletonList(recurringTransaction));
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE);
Mockito.when(this.ruleService.isWeekend(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE);
final LocalDate now = LocalDate.of(2019, 6, 15);
// Act
final Iterable<RecurringTransaction> 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).minusMonths(1));
recurringTransaction.setHolidayWeekendType(HolidayWeekendType.PREVIOUS_WORKDAY);
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
return recurringTransaction;
}
}

View File

@@ -0,0 +1,70 @@
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_SAME_DAYTest {
@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 = same_day is not due today, if yesterday
* was a holiday but today is not
*/
@Test
public void test_getAllDueToday_duePast_holiday() {
// Arrange
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.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<RecurringTransaction> 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).minusMonths(1));
recurringTransaction.setHolidayWeekendType(HolidayWeekendType.SAME_DAY);
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
recurringTransaction.setDeleted(false);
return recurringTransaction;
}
}

View File

@@ -0,0 +1,71 @@
package de.financer.service;
import de.financer.model.Account;
import de.financer.model.AccountType;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public class RuleService_getMultiplierFromAccountTest {
private RuleService classUnderTest;
@Before
public void setUp() {
this.classUnderTest = new RuleService();
this.classUnderTest.afterPropertiesSet();
}
@Test
public void test_getMultiplierFromAccount_INCOME() {
doTest(AccountType.INCOME, 1);
}
@Test
public void test_getMultiplierFromAccount_BANK() {
doTest(AccountType.BANK, -1);
}
@Test
public void test_getMultiplierFromAccount_CASH() {
doTest(AccountType.CASH, -1);
}
@Test
public void test_getMultiplierFromAccount_EXPENSE() {
doTest(AccountType.EXPENSE, 1);
}
@Test
public void test_getMultiplierFromAccount_LIABILITY() {
doTest(AccountType.LIABILITY, 1);
}
@Test
public void test_getMultiplierFromAccount_START() {
doTest(AccountType.START, 1);
}
public void doTest(AccountType accountType, long expected) {
// Arrange
final Account fromAccount = createAccount(accountType);
// Act
final long multiplier = this.classUnderTest.getMultiplierFromAccount(fromAccount);
// Assert
Assert.assertEquals(expected, multiplier);
}
private Account createAccount(AccountType accountType) {
final Account account = new Account();
account.setType(accountType);
return account;
}
}

View File

@@ -0,0 +1,71 @@
package de.financer.service;
import de.financer.model.Account;
import de.financer.model.AccountType;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public class RuleService_getMultiplierToAccountTest {
private RuleService classUnderTest;
@Before
public void setUp() {
this.classUnderTest = new RuleService();
this.classUnderTest.afterPropertiesSet();
}
@Test
public void test_getMultiplierToAccount_INCOME() {
doTest(AccountType.INCOME, -1);
}
@Test
public void test_getMultiplierToAccount_BANK() {
doTest(AccountType.BANK, 1);
}
@Test
public void test_getMultiplierToAccount_CASH() {
doTest(AccountType.CASH, 1);
}
@Test
public void test_getMultiplierToAccount_EXPENSE() {
doTest(AccountType.EXPENSE, 1);
}
@Test
public void test_getMultiplierToAccount_LIABILITY() {
doTest(AccountType.LIABILITY, -1);
}
@Test
public void test_getMultiplierToAccount_START() {
doTest(AccountType.START, -1);
}
public void doTest(AccountType accountType, long expected) {
// Arrange
final Account fromAccount = createAccount(accountType);
// Act
final long multiplier = this.classUnderTest.getMultiplierToAccount(fromAccount);
// Assert
Assert.assertEquals(expected, multiplier);
}
private Account createAccount(AccountType accountType) {
final Account account = new Account();
account.setType(accountType);
return account;
}
}

View File

@@ -0,0 +1,233 @@
package de.financer.service;
import de.financer.model.Account;
import de.financer.model.AccountType;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public class RuleService_isValidBookingTest {
private RuleService classUnderTest;
@Before
public void setUp() {
this.classUnderTest = new RuleService();
this.classUnderTest.afterPropertiesSet();
}
// from INCOME
@Test
public void test_isValidBooking_INCOME_INCOME() {
doTest(AccountType.INCOME, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_INCOME_BANK() {
doTest(AccountType.INCOME, AccountType.BANK, true);
}
@Test
public void test_isValidBooking_INCOME_CASH() {
doTest(AccountType.INCOME, AccountType.CASH, true);
}
@Test
public void test_isValidBooking_INCOME_EXPENSE() {
doTest(AccountType.INCOME, AccountType.EXPENSE, false);
}
@Test
public void test_isValidBooking_INCOME_LIABILITY() {
doTest(AccountType.INCOME, AccountType.LIABILITY, false);
}
@Test
public void test_isValidBooking_INCOME_START() {
doTest(AccountType.INCOME, AccountType.START, false);
}
// from BANK
@Test
public void test_isValidBooking_BANK_INCOME() {
doTest(AccountType.BANK, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_BANK_BANK() {
doTest(AccountType.BANK, AccountType.BANK, true);
}
@Test
public void test_isValidBooking_BANK_CASH() {
doTest(AccountType.BANK, AccountType.CASH, true);
}
@Test
public void test_isValidBooking_BANK_EXPENSE() {
doTest(AccountType.BANK, AccountType.EXPENSE, true);
}
@Test
public void test_isValidBooking_BANK_LIABILITY() {
doTest(AccountType.BANK, AccountType.LIABILITY, true);
}
@Test
public void test_isValidBooking_BANK_START() {
doTest(AccountType.BANK, AccountType.START, false);
}
// from CASH
@Test
public void test_isValidBooking_CASH_INCOME() {
doTest(AccountType.CASH, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_CASH_BANK() {
doTest(AccountType.CASH, AccountType.BANK, true);
}
@Test
public void test_isValidBooking_CASH_CASH() {
doTest(AccountType.CASH, AccountType.CASH, false);
}
@Test
public void test_isValidBooking_CASH_EXPENSE() {
doTest(AccountType.CASH, AccountType.EXPENSE, true);
}
@Test
public void test_isValidBooking_CASH_LIABILITY() {
doTest(AccountType.CASH, AccountType.LIABILITY, true);
}
@Test
public void test_isValidBooking_CASH_START() {
doTest(AccountType.CASH, AccountType.START, false);
}
// from EXPENSE
@Test
public void test_isValidBooking_EXPENSE_INCOME() {
doTest(AccountType.EXPENSE, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_EXPENSE_BANK() {
doTest(AccountType.EXPENSE, AccountType.BANK, false);
}
@Test
public void test_isValidBooking_EXPENSE_CASH() {
doTest(AccountType.EXPENSE, AccountType.CASH, false);
}
@Test
public void test_isValidBooking_EXPENSE_EXPENSE() {
doTest(AccountType.EXPENSE, AccountType.EXPENSE, false);
}
@Test
public void test_isValidBooking_EXPENSE_LIABILITY() {
doTest(AccountType.EXPENSE, AccountType.LIABILITY, false);
}
@Test
public void test_isValidBooking_EXPENSE_START() {
doTest(AccountType.EXPENSE, AccountType.START, false);
}
// from LIABILITY
@Test
public void test_isValidBooking_LIABILITY_INCOME() {
doTest(AccountType.LIABILITY, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_LIABILITY_BANK() {
doTest(AccountType.LIABILITY, AccountType.BANK, true);
}
@Test
public void test_isValidBooking_LIABILITY_CASH() {
doTest(AccountType.LIABILITY, AccountType.CASH, true);
}
@Test
public void test_isValidBooking_LIABILITY_EXPENSE() {
doTest(AccountType.LIABILITY, AccountType.EXPENSE, true);
}
@Test
public void test_isValidBooking_LIABILITY_LIABILITY() {
doTest(AccountType.LIABILITY, AccountType.LIABILITY, false);
}
@Test
public void test_isValidBooking_LIABILITY_START() {
doTest(AccountType.LIABILITY, AccountType.START, false);
}
// from START
@Test
public void test_isValidBooking_START_INCOME() {
doTest(AccountType.START, AccountType.INCOME, false);
}
@Test
public void test_isValidBooking_START_BANK() {
doTest(AccountType.START, AccountType.BANK, true);
}
@Test
public void test_isValidBooking_START_CASH() {
doTest(AccountType.START, AccountType.CASH, true);
}
@Test
public void test_isValidBooking_START_EXPENSE() {
doTest(AccountType.START, AccountType.EXPENSE, false);
}
@Test
public void test_isValidBooking_START_LIABILITY() {
doTest(AccountType.START, AccountType.LIABILITY, true);
}
@Test
public void test_isValidBooking_START_START() {
doTest(AccountType.START, AccountType.START, false);
}
private void doTest(AccountType fromAccountType, AccountType toAccountType, boolean expected) {
// Arrange
final Account fromAccount = createAccount(fromAccountType);
final Account toAccount = createAccount(toAccountType);
// Act
final boolean isValid = this.classUnderTest.isValidBooking(fromAccount, toAccount);
// Assert
Assert.assertEquals(expected, isValid);
}
private Account createAccount(AccountType accountType) {
final Account account = new Account();
account.setType(accountType);
return account;
}
}

View File

@@ -0,0 +1,168 @@
package de.financer.service;
import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.dba.TransactionRepository;
import de.financer.model.Account;
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 TransactionService_createTransactionTest {
@InjectMocks
private TransactionService classUnderTest;
@Mock
private AccountService accountService;
@Mock
private RuleService ruleService;
@Mock
private TransactionRepository transactionRepository;
@Mock
private FinancerConfig financerConfig;
@Before
public void setUp() {
Mockito.when(this.financerConfig.getDateFormat()).thenReturn("dd.MM.yyyy");
}
@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.from", "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.to", 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.from", "account.to", 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.from", "account.to", 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.from", "account.to", 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.from", "account.to", 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.from", "account.to", 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.from", "account.to", 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));
}
private Account createAccount() {
final Account account = new Account();
account.setCurrentBalance(Long.valueOf(0l));
return account;
}
}

View File

@@ -0,0 +1,129 @@
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.Transaction;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.List;
import java.util.Optional;
@RunWith(MockitoJUnitRunner.class)
public class TransactionService_deleteTransactionTest {
@InjectMocks
private TransactionService classUnderTest;
@Mock
private AccountService accountService;
@Mock
private RuleService ruleService;
@Mock
private TransactionRepository transactionRepository;
@Mock
private FinancerConfig financerConfig;
@Before
public void setUp() {
this.ruleService.afterPropertiesSet();
Mockito.when(this.ruleService.getMultiplierFromAccount(Mockito.any())).thenCallRealMethod();
Mockito.when(this.ruleService.getMultiplierToAccount(Mockito.any())).thenCallRealMethod();
}
@Test
public void test_deleteRecurringTransaction_MISSING_TRANSACTION_ID() {
// Arrange
// Nothing to do
// Act
final ResponseReason response = this.classUnderTest.deleteTransaction(null);
// Assert
Assert.assertEquals(ResponseReason.MISSING_TRANSACTION_ID, response);
}
@Test
public void test_deleteRecurringTransaction_INVALID_TRANSACTION_ID() {
// Arrange
// Nothing to do
// Act
final ResponseReason response = this.classUnderTest.deleteTransaction("invalid");
// Assert
Assert.assertEquals(ResponseReason.INVALID_TRANSACTION_ID, response);
}
@Test
public void test_deleteRecurringTransaction_TRANSACTION_NOT_FOUND() {
// Arrange
Mockito.when(this.transactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.empty());
// Act
final ResponseReason response = this.classUnderTest.deleteTransaction("123");
// Assert
Assert.assertEquals(ResponseReason.TRANSACTION_NOT_FOUND, response);
}
@Test
public void test_deleteRecurringTransaction_UNKNOWN_ERROR() {
// Arrange
Mockito.when(this.transactionRepository.findById(Mockito.anyLong()))
.thenReturn(Optional.of(createTransaction(AccountType.BANK, AccountType.EXPENSE)));
Mockito.doThrow(new NullPointerException()).when(this.transactionRepository).deleteById(Mockito.anyLong());
// Act
final ResponseReason response = this.classUnderTest.deleteTransaction("123");
// Assert
Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
}
@Test
public void test_deleteRecurringTransaction_OK() {
// Arrange
Mockito.when(this.transactionRepository.findById(Mockito.anyLong()))
.thenReturn(Optional.of(createTransaction(AccountType.BANK, AccountType.EXPENSE)));
// Act
final ResponseReason response = this.classUnderTest.deleteTransaction("123");
// Assert
Assert.assertEquals(ResponseReason.OK, response);
final InOrder inOrder = Mockito.inOrder(this.accountService);
inOrder.verify(this.accountService).saveAccount(ArgumentMatchers.argThat((Account arg) -> Long.valueOf(50000L).equals(arg.getCurrentBalance())));
inOrder.verify(this.accountService).saveAccount(ArgumentMatchers.argThat((Account arg) -> Long.valueOf(5000L).equals(arg.getCurrentBalance())));
}
private Transaction createTransaction(AccountType fromType, AccountType toType) {
final Transaction transaction = new Transaction();
final Account fromAccount = new Account();
final Account toAccount = new Account();
transaction.setFromAccount(fromAccount);
transaction.setToAccount(toAccount);
transaction.setAmount(Long.valueOf(10000L));
fromAccount.setCurrentBalance(Long.valueOf(40000L));
toAccount.setCurrentBalance(Long.valueOf(15000L));
fromAccount.setType(fromType);
toAccount.setType(toType);
return transaction;
}
}

View File

@@ -0,0 +1,74 @@
package de.financer.task;
import de.financer.config.FinancerConfig;
import de.financer.model.Account;
import de.financer.model.RecurringTransaction;
import de.financer.service.RecurringTransactionService;
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 org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@RunWith(MockitoJUnitRunner.class)
public class SendRecurringTransactionReminderTaskTest {
@InjectMocks
private SendRecurringTransactionReminderTask classUnderTest;
@Mock
private RecurringTransactionService recurringTransactionService;
@Mock
private JavaMailSender mailSender;
@Mock
private FinancerConfig financerConfig;
@Test
public void test_sendReminder() {
// Arrange
final Collection<RecurringTransaction> recurringTransactions = Arrays.asList(
createRecurringTransaction("Test booking 1", "Income", "accounts.bank", Long.valueOf(250000), true),
createRecurringTransaction("Test booking 2", "Bank", "accounts.rent", Long.valueOf(41500), true),
createRecurringTransaction("Test booking 3", "Bank", "accounts.cash", Long.valueOf(5000), true),
createRecurringTransaction("Test booking 4", "Car", "accounts.car", Long.valueOf(1234), false)
);
Mockito.when(this.recurringTransactionService.getAllDueToday()).thenReturn(recurringTransactions);
Mockito.when(this.financerConfig.getMailRecipients()).thenReturn(Collections.singletonList("test@test.com"));
// Act
this.classUnderTest.sendReminder();
// Assert
Mockito.verify(this.mailSender, Mockito.times(1)).send(Mockito.any(SimpleMailMessage.class));
}
private RecurringTransaction createRecurringTransaction(String description, String fromAccountKey, String toAccountKey, Long amount, boolean remind) {
final RecurringTransaction recurringTransaction = new RecurringTransaction();
recurringTransaction.setDescription(description);
recurringTransaction.setFromAccount(createAccount(fromAccountKey));
recurringTransaction.setToAccount(createAccount(toAccountKey));
recurringTransaction.setAmount(amount);
recurringTransaction.setRemind(remind);
return recurringTransaction;
}
private Account createAccount(String key) {
final Account account = new Account();
account.setKey(key);
return account;
}
}

View File

@@ -0,0 +1,5 @@
spring.profiles.active=hsqldb,dev
spring.datasource.url=jdbc:hsqldb:mem:.
spring.datasource.username=sa
spring.flyway.locations=classpath:/database/hsqldb,classpath:/database/hsqldb/integration,classpath:/database/common

View File

@@ -0,0 +1,13 @@
-- Accounts
INSERT INTO account ("key", type, status, current_balance)
VALUES ('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 ((SELECT ID FROM account WHERE "key" = 'Income'), (SELECT ID FROM account WHERE "key" = 'Check account'), '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 ((SELECT ID FROM account WHERE "key" = 'Cash'), (SELECT ID FROM account WHERE "key" = 'Convenience'), 'Pretzel', 170, 'DAILY', '2019-02-20', 'SAME_DAY');
INSERT INTO recurring_transaction (from_account_id, to_account_id, description, amount, interval_type, first_occurrence, last_occurrence, holiday_weekend_type)
VALUES ((SELECT ID FROM account WHERE "key" = 'Cash'), (SELECT ID FROM account WHERE "key" = 'Food (external)'), 'McDonalds Happy Meal', 399, 'WEEKLY', '2019-02-20', '2019-03-20', 'SAME_DAY');