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

3
financer-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
financer-server.log*
.attach*
*.iml

View File

@@ -0,0 +1,36 @@
___ _
/ __(_)_ __ __ _ _ __ ___ ___ _ __
/ _\ | | '_ \ / _` | '_ \ / __/ _ \ '__|
/ / | | | | | (_| | | | | (_| __/ |
\/ |_|_| |_|\__,_|_| |_|\___\___|_|
1. About
2. Content
3. Overview
4. Architectural overview
5. Account types
6. Booking rules
7. Setup
7. Setup
========
This chapter explains how to setup a financer instance. It requires PostgreSQL as a database backend and a Java
Servlet Container (e.g. Apache Tomcat) as a runtime environment.
7.1 Database setup
------------------
First install PostgreSQL. Then create a user for financer:
sudo -iu postgres
createuser -P -s -e financer
This creates a user named 'financer' and prompts for the creation of a password for this user. The expected default
password is 'financer'. Then create the actual database:
createdb financer
Using 'financer' for the name of the user, its password and the database name is the expected default. If you want
any other values you need to adjust the database connection settings of the financer application.
Then you need to grant the created user permission to the created database:
psql
GRANT ALL PRIVILEGES ON DATABASE "financer" to financer;
\q
exit

124
financer-server/pom.xml Normal file
View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.77zzcx7.financer</groupId>
<artifactId>financer-parent</artifactId>
<version>9-SNAPSHOT</version>
<relativePath>../financer-parent</relativePath>
</parent>
<groupId>de.77zzcx7.financer</groupId>
<artifactId>financer-server</artifactId>
<packaging>${packaging.type}</packaging>
<description>The server part of the financer application - a simple app to manage your personal finances</description>
<name>financer-server</name>
<properties>
<packaging.type>jar</packaging.type>
<activeProfiles>hsqldb,dev</activeProfiles>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Apache commons -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
<!-- Misc dependencies -->
<dependency>
<groupId>de.jollyday</groupId>
<artifactId>jollyday</artifactId>
<version>0.5.7</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency>
<!-- Runtime dependencies -->
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<reporting>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<configuration>
<show>private</show>
<javadocExecutable>/usr/bin/javadoc</javadocExecutable>
</configuration>
</plugin>
</plugins>
</reporting>
<profiles>
<profile>
<id>build-war</id>
<properties>
<packaging.type>war</packaging.type>
<activeProfiles>postgres</activeProfiles>
</properties>
<build>
<finalName>${project.artifactId}##${parallelDeploymentVersion}</finalName>
</build>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</profile>
</profiles>
</project>

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');