diff --git a/financer-server/.gitignore b/financer-server/.gitignore new file mode 100644 index 0000000..e831c8c --- /dev/null +++ b/financer-server/.gitignore @@ -0,0 +1,3 @@ +financer-server.log* +.attach* +*.iml \ No newline at end of file diff --git a/financer-server/doc/README b/financer-server/doc/README new file mode 100644 index 0000000..b860396 --- /dev/null +++ b/financer-server/doc/README @@ -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 \ No newline at end of file diff --git a/financer-server/pom.xml b/financer-server/pom.xml new file mode 100644 index 0000000..b68ab8c --- /dev/null +++ b/financer-server/pom.xml @@ -0,0 +1,124 @@ + + + + 4.0.0 + + + de.77zzcx7.financer + financer-parent + 9-SNAPSHOT + ../financer-parent + + + de.77zzcx7.financer + financer-server + ${packaging.type} + The server part of the financer application - a simple app to manage your personal finances + financer-server + + + jar + hsqldb,dev + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-web + + + org.flywaydb + flyway-core + + + + org.apache.commons + commons-lang3 + + + org.apache.commons + commons-collections4 + + + + de.jollyday + jollyday + 0.5.7 + + + org.glassfish.jaxb + jaxb-runtime + + + + + org.hsqldb + hsqldb + runtime + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + junit + junit + test + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + private + /usr/bin/javadoc + + + + + + + + build-war + + war + postgres + + + ${project.artifactId}##${parallelDeploymentVersion} + + + + org.springframework.boot + spring-boot-starter-tomcat + provided + + + + + diff --git a/financer-server/src/main/java/de/financer/FinancerApplication.java b/financer-server/src/main/java/de/financer/FinancerApplication.java new file mode 100644 index 0000000..e084a5c --- /dev/null +++ b/financer-server/src/main/java/de/financer/FinancerApplication.java @@ -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); + } +} diff --git a/financer-server/src/main/java/de/financer/ResponseReason.java b/financer-server/src/main/java/de/financer/ResponseReason.java new file mode 100644 index 0000000..e5877cb --- /dev/null +++ b/financer-server/src/main/java/de/financer/ResponseReason.java @@ -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); + } +} diff --git a/financer-server/src/main/java/de/financer/config/FinancerConfig.java b/financer-server/src/main/java/de/financer/config/FinancerConfig.java new file mode 100644 index 0000000..3e8a5f6 --- /dev/null +++ b/financer-server/src/main/java/de/financer/config/FinancerConfig.java @@ -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 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 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 getMailRecipients() { + return mailRecipients; + } + + public void setMailRecipients(Collection 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; + } +} diff --git a/financer-server/src/main/java/de/financer/controller/AccountController.java b/financer-server/src/main/java/de/financer/controller/AccountController.java new file mode 100644 index 0000000..46b0b04 --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/AccountController.java @@ -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 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(); + } +} diff --git a/financer-server/src/main/java/de/financer/controller/AccountGroupController.java b/financer-server/src/main/java/de/financer/controller/AccountGroupController.java new file mode 100644 index 0000000..109f9ce --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/AccountGroupController.java @@ -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 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(); + } +} diff --git a/financer-server/src/main/java/de/financer/controller/ControllerUtil.java b/financer-server/src/main/java/de/financer/controller/ControllerUtil.java new file mode 100644 index 0000000..bf5711e --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/ControllerUtil.java @@ -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 %20 with a space. + * + * @param toDecode the string to decode + * @return the decoded string in UTF-8 or, if UTF-8 is not available for whatever reason, the encoded string + */ + public static final String urlDecode(String toDecode) { + try { + return URLDecoder.decode(toDecode, StandardCharsets.UTF_8.name()); + } + catch (UnsupportedEncodingException e) { + return toDecode; + } + } +} diff --git a/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java b/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java new file mode 100644 index 0000000..91738b7 --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/RecurringTransactionController.java @@ -0,0 +1,119 @@ +package de.financer.controller; + +import de.financer.ResponseReason; +import de.financer.model.RecurringTransaction; +import de.financer.service.RecurringTransactionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +@RestController +@RequestMapping("recurringTransactions") +public class RecurringTransactionController { + + private static final Logger LOGGER = LoggerFactory.getLogger(RecurringTransactionController.class); + + @Autowired + private RecurringTransactionService recurringTransactionService; + + @RequestMapping("getAll") + public Iterable getAll() { + return this.recurringTransactionService.getAll(); + } + + @RequestMapping("getAllActive") + public Iterable getAllActive() { + return this.recurringTransactionService.getAllActive(); + } + + @RequestMapping("getAllForAccount") + public Iterable 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 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(); + } +} diff --git a/financer-server/src/main/java/de/financer/controller/TransactionController.java b/financer-server/src/main/java/de/financer/controller/TransactionController.java new file mode 100644 index 0000000..71de55a --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/TransactionController.java @@ -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 getAll() { + return this.transactionService.getAll(); + } + + @RequestMapping("getAllForAccount") + public Iterable 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(); + } +} diff --git a/financer-server/src/main/java/de/financer/dba/AccountGroupRepository.java b/financer-server/src/main/java/de/financer/dba/AccountGroupRepository.java new file mode 100644 index 0000000..144cb66 --- /dev/null +++ b/financer-server/src/main/java/de/financer/dba/AccountGroupRepository.java @@ -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 findByName(String name); +} diff --git a/financer-server/src/main/java/de/financer/dba/AccountRepository.java b/financer-server/src/main/java/de/financer/dba/AccountRepository.java new file mode 100644 index 0000000..13cdf21 --- /dev/null +++ b/financer-server/src/main/java/de/financer/dba/AccountRepository.java @@ -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 findByKey(String key); +} diff --git a/financer-server/src/main/java/de/financer/dba/RecurringTransactionRepository.java b/financer-server/src/main/java/de/financer/dba/RecurringTransactionRepository.java new file mode 100644 index 0000000..712bff2 --- /dev/null +++ b/financer-server/src/main/java/de/financer/dba/RecurringTransactionRepository.java @@ -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 { + Iterable 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 findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate lastOccurrence); + + Iterable findByDeletedFalse(); +} diff --git a/financer-server/src/main/java/de/financer/dba/TransactionRepository.java b/financer-server/src/main/java/de/financer/dba/TransactionRepository.java new file mode 100644 index 0000000..f62e74c --- /dev/null +++ b/financer-server/src/main/java/de/financer/dba/TransactionRepository.java @@ -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 { + Iterable findTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount); +} diff --git a/financer-server/src/main/java/de/financer/model/Account.java b/financer-server/src/main/java/de/financer/model/Account.java new file mode 100644 index 0000000..732b947 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/Account.java @@ -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; + } +} diff --git a/financer-server/src/main/java/de/financer/model/AccountGroup.java b/financer-server/src/main/java/de/financer/model/AccountGroup.java new file mode 100644 index 0000000..1ec6d15 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/AccountGroup.java @@ -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; + } +} diff --git a/financer-server/src/main/java/de/financer/model/AccountStatus.java b/financer-server/src/main/java/de/financer/model/AccountStatus.java new file mode 100644 index 0000000..9298e31 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/AccountStatus.java @@ -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)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/AccountType.java b/financer-server/src/main/java/de/financer/model/AccountType.java new file mode 100644 index 0000000..007146b --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/AccountType.java @@ -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)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/HolidayWeekendType.java b/financer-server/src/main/java/de/financer/model/HolidayWeekendType.java new file mode 100644 index 0000000..b25cbdb --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/HolidayWeekendType.java @@ -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, + + /** + *

+ * Indicates that the action should be deferred to the next workday. + *

+ *
+     *     Example 1:
+     *     MO   TU   WE   TH   FR   SA   SO
+     *               H              WE   WE   -> Holiday/WeekEnd
+     *               X                        -> Due date of action
+     *                    X'                  -> Deferred, effective due date of action
+     * 
+ *
+     *     Example 2:
+     *     TU   WE   TH   FR   SA   SO   MO
+     *          H              WE   WE        -> Holiday/WeekEnd
+     *                         X              -> Due date of action
+     *                                   X'   -> Deferred, effective due date of action
+     * 
+ * + */ + NEXT_WORKDAY, + + /** + *

+ * Indicates that the action should preponed to the previous day + *

+ *
+     *     Example 1:
+     *     MO   TU   WE   TH   FR   SA   SO
+     *               H              WE   WE   -> Holiday/WeekEnd
+     *               X                        -> Due date of action
+     *          X'                            -> Earlier, effective due date of action
+     * 
+ *
+     *     Example 2:
+     *     MO   TU   WE   TH   FR   SA   SO
+     *                         H    WE   WE   -> Holiday/WeekEnd
+     *                                   X    -> Due date of action
+     *                    X'                  -> Earlier, effective due date of action
+     * 
+ */ + PREVIOUS_WORKDAY; + + /** + * This method validates whether the given string represents a valid holiday weekend type. + * + * @param type to check + * @return whether the given type represents a valid holiday weekend type + */ + public static boolean isValidType(String type) { + return Arrays.stream(HolidayWeekendType.values()).anyMatch((holidayWeekendType) -> holidayWeekendType.name().equals(type)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/IntervalType.java b/financer-server/src/main/java/de/financer/model/IntervalType.java new file mode 100644 index 0000000..952f9b8 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/IntervalType.java @@ -0,0 +1,30 @@ +package de.financer.model; + +import java.util.Arrays; + +public enum IntervalType { + /** Indicates that an action should be executed every day */ + DAILY, + + /** Indicates that an action should be executed once a week */ + WEEKLY, + + /** Indicates that an action should be executed once a month */ + MONTHLY, + + /** Indicates that an action should be executed once a quarter */ + QUARTERLY, + + /** Indicates that an action should be executed once a year */ + YEARLY; + + /** + * This method validates whether the given string represents a valid interval type. + * + * @param type to check + * @return whether the given type represents a valid interval type + */ + public static boolean isValidType(String type) { + return Arrays.stream(IntervalType.values()).anyMatch((intervalType) -> intervalType.name().equals(type)); + } +} diff --git a/financer-server/src/main/java/de/financer/model/RecurringTransaction.java b/financer-server/src/main/java/de/financer/model/RecurringTransaction.java new file mode 100644 index 0000000..135fabe --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/RecurringTransaction.java @@ -0,0 +1,109 @@ +package de.financer.model; + +import javax.persistence.*; +import java.time.LocalDate; + +@Entity +public class RecurringTransaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private Account fromAccount; + @OneToOne(fetch = FetchType.EAGER) + private Account toAccount; + private String description; + private Long amount; + @Enumerated(EnumType.STRING) + private IntervalType intervalType; + private LocalDate firstOccurrence; + private LocalDate lastOccurrence; + @Enumerated(EnumType.STRING) + private HolidayWeekendType holidayWeekendType; + private boolean deleted; + private boolean remind; + + public Long getId() { + return id; + } + + public Account getFromAccount() { + return fromAccount; + } + + public void setFromAccount(Account fromAccount) { + this.fromAccount = fromAccount; + } + + public Account getToAccount() { + return toAccount; + } + + public void setToAccount(Account toAccount) { + this.toAccount = toAccount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public HolidayWeekendType getHolidayWeekendType() { + return holidayWeekendType; + } + + public void setHolidayWeekendType(HolidayWeekendType holidayWeekendType) { + this.holidayWeekendType = holidayWeekendType; + } + + public LocalDate getLastOccurrence() { + return lastOccurrence; + } + + public void setLastOccurrence(LocalDate lastOccurrence) { + this.lastOccurrence = lastOccurrence; + } + + public LocalDate getFirstOccurrence() { + return firstOccurrence; + } + + public void setFirstOccurrence(LocalDate firstOccurrence) { + this.firstOccurrence = firstOccurrence; + } + + public IntervalType getIntervalType() { + return intervalType; + } + + public void setIntervalType(IntervalType intervalType) { + this.intervalType = intervalType; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + + public boolean isRemind() { + return remind; + } + + public void setRemind(boolean remind) { + this.remind = remind; + } +} diff --git a/financer-server/src/main/java/de/financer/model/Transaction.java b/financer-server/src/main/java/de/financer/model/Transaction.java new file mode 100644 index 0000000..0b33788 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/Transaction.java @@ -0,0 +1,74 @@ +package de.financer.model; + +import javax.persistence.*; +import java.time.LocalDate; + +@Entity +@Table(name = "\"transaction\"") +public class Transaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private Account fromAccount; + @OneToOne(fetch = FetchType.EAGER) + private Account toAccount; + @Column(name = "\"date\"") + private LocalDate date; + private String description; + private Long amount; + @ManyToOne(fetch = FetchType.EAGER) + private RecurringTransaction recurringTransaction; + + public Long getId() { + return id; + } + + public Account getFromAccount() { + return fromAccount; + } + + public void setFromAccount(Account fromAccount) { + this.fromAccount = fromAccount; + } + + public Account getToAccount() { + return toAccount; + } + + public void setToAccount(Account toAccount) { + this.toAccount = toAccount; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public RecurringTransaction getRecurringTransaction() { + return recurringTransaction; + } + + public void setRecurringTransaction(RecurringTransaction recurringTransaction) { + this.recurringTransaction = recurringTransaction; + } +} diff --git a/financer-server/src/main/java/de/financer/model/package-info.java b/financer-server/src/main/java/de/financer/model/package-info.java new file mode 100644 index 0000000..421aaf9 --- /dev/null +++ b/financer-server/src/main/java/de/financer/model/package-info.java @@ -0,0 +1,10 @@ +/** + *

+ * This package contains the main model for the financer application. + * In the DDD (Domain Driven Design) sense the models are anemic + * as they contain no logic themselves but act as mere POJOs with additional + * Hibernate annotations. The (business) logic is located in the services of the + * {@link de.financer.service} package. + *

+ */ +package de.financer.model; \ No newline at end of file diff --git a/financer-server/src/main/java/de/financer/service/AccountGroupService.java b/financer-server/src/main/java/de/financer/service/AccountGroupService.java new file mode 100644 index 0000000..1b41de9 --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/AccountGroupService.java @@ -0,0 +1,69 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountGroupRepository; +import de.financer.model.AccountGroup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountGroupService { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountGroupService.class); + + @Autowired + private AccountGroupRepository accountGroupRepository; + + /** + * @return all existing account groups + */ + public Iterable 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 null if no account group with the given name can be found + */ + public AccountGroup getAccountGroupByName(String name) { + return this.accountGroupRepository.findByName(name); + } + + /** + * This method creates a new account group with the given name. + * + * @param name the name of the new account group + * @return {@link ResponseReason#DUPLICATE_ACCOUNT_GROUP_NAME} if an account group with the given name already exists, + * {@link ResponseReason#UNKNOWN_ERROR} if an unknown error occurs, + * {@link ResponseReason#OK} if the operation completed successfully. + * Never returns null. + */ + @Transactional(propagation = Propagation.SUPPORTS) + public ResponseReason createAccountGroup(String name) { + final AccountGroup accountGroup = new AccountGroup(); + + accountGroup.setName(name); + + try { + this.accountGroupRepository.save(accountGroup); + } + catch (DataIntegrityViolationException dive) { + LOGGER.error(String.format("Duplicate account group name! %s", name), dive); + + return ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME; + } + catch (Exception e) { + LOGGER.error(String.format("Could not save account group %s", name), e); + + return ResponseReason.UNKNOWN_ERROR; + } + + return ResponseReason.OK; + } +} diff --git a/financer-server/src/main/java/de/financer/service/AccountService.java b/financer-server/src/main/java/de/financer/service/AccountService.java new file mode 100644 index 0000000..9e4559a --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/AccountService.java @@ -0,0 +1,145 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountRepository; +import de.financer.model.Account; +import de.financer.model.AccountGroup; +import de.financer.model.AccountStatus; +import de.financer.model.AccountType; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.stream.Collectors; + +@Service +public class AccountService { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountService.class); + + @Autowired + private AccountRepository accountRepository; + @Autowired + private AccountGroupService accountGroupService; + + /** + * This method returns the account identified by the given key. + * + * @param key the key to get the account for + * @return the account or null if no account with the given key can be found + */ + public Account getAccountByKey(String key) { + return this.accountRepository.findByKey(key); + } + + /** + * @return all existing accounts, regardless of their type or status. This explicitly covers accounts in {@link AccountStatus#CLOSED CLOSED} as well. + */ + public Iterable 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 0. + * + * @param key the key of the new account + * @param type the type of the new account. Must be one of {@link AccountType}. + * @param accountGroupName the name of the account group to use, can be null + * @return {@link ResponseReason#INVALID_ACCOUNT_TYPE} if the given type is not a valid {@link AccountType}, + * {@link ResponseReason#UNKNOWN_ERROR} if an unexpected error occurs, + * {@link ResponseReason#OK} if the operation completed successfully, + * {@link ResponseReason#DUPLICATE_ACCOUNT_KEY} if an account with the given key + * already exists and {@link ResponseReason#ACCOUNT_GROUP_NOT_FOUND} if the optional parameter + * accountGroupName does not identify a valid account group. Never returns null. + */ + @Transactional(propagation = Propagation.SUPPORTS) + public ResponseReason createAccount(String key, String type, String accountGroupName) { + if (!AccountType.isValidType(type)) { + return ResponseReason.INVALID_ACCOUNT_TYPE; + } + + final Account account = new Account(); + + if (StringUtils.isNotEmpty(accountGroupName)) { + final AccountGroup accountGroup = this.accountGroupService.getAccountGroupByName(accountGroupName); + + if (accountGroup == null) { + return ResponseReason.ACCOUNT_GROUP_NOT_FOUND; // early return + } + + account.setAccountGroup(accountGroup); + } + + account.setKey(key); + account.setType(AccountType.valueOf(type)); + // If we create an account it's implicitly open + account.setStatus(AccountStatus.OPEN); + // and has a current balance of 0 + account.setCurrentBalance(Long.valueOf(0L)); + + try { + this.accountRepository.save(account); + } + catch (DataIntegrityViolationException dive) { + LOGGER.error(String.format("Duplicate key! %s|%s|%s", key, type, accountGroupName), dive); + + return ResponseReason.DUPLICATE_ACCOUNT_KEY; + } + catch (Exception e) { + LOGGER.error(String.format("Could not save account %s|%s|%s", key, type, accountGroupName), e); + + return ResponseReason.UNKNOWN_ERROR; + } + + return ResponseReason.OK; + } + + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason closeAccount(String key) { + return setAccountStatus(key, AccountStatus.CLOSED); + } + + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason openAccount(String key) { + return setAccountStatus(key, AccountStatus.OPEN); + } + + // Visible for unit tests + /* package */ ResponseReason setAccountStatus(String key, AccountStatus accountStatus) { + final Account account = this.accountRepository.findByKey(key); + + if (account == null) { + return ResponseReason.ACCOUNT_NOT_FOUND; + } + + account.setStatus(accountStatus); + + try { + this.accountRepository.save(account); + } + catch (Exception e) { + LOGGER.error(String.format("Could not update account status %s|%s", key, accountStatus.name()), e); + + return ResponseReason.UNKNOWN_ERROR; + } + + return ResponseReason.OK; + } +} diff --git a/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java new file mode 100644 index 0000000..a9bb2b3 --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java @@ -0,0 +1,498 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.config.FinancerConfig; +import de.financer.dba.RecurringTransactionRepository; +import de.financer.model.Account; +import de.financer.model.HolidayWeekendType; +import de.financer.model.IntervalType; +import de.financer.model.RecurringTransaction; +import org.apache.commons.collections4.IterableUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class RecurringTransactionService { + private static final Logger LOGGER = LoggerFactory.getLogger(RecurringTransactionService.class); + + @Autowired + private RecurringTransactionRepository recurringTransactionRepository; + + @Autowired + private AccountService accountService; + + @Autowired + private RuleService ruleService; + + @Autowired + private FinancerConfig financerConfig; + + @Autowired + private TransactionService transactionService; + + public Iterable getAll() { + return this.recurringTransactionRepository.findByDeletedFalse(); + } + + public Iterable getAllActive() { + return this.recurringTransactionRepository + .findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate.now()); + } + + public Iterable 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 getAllDueToday() { + return this.getAllDueToday(LocalDate.now()); + } + + // Visible for unit tests + /* package */ Iterable 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 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 true if the recurring transaction is due today, false otherwise + */ + private boolean checkRecurringTransactionDueToday(RecurringTransaction recurringTransaction, LocalDate now) { + // If a recurring transactions first occurrence is in the future it can never be relevant for this + // method. This case will be handled in the checkRecurringTransactionDueFuture method if the recurring + // transaction also has HolidayWeekendType#PREVIOUS_WORKDAY. + // If this check is not done the datesUntil(...) call will fail as it expects that the callees date is lower + // or equal the first parameter which is not the case for the following example: + // callee.firstOccurrence = 2019-05-27 + // now = 2019-05-14 + // now.plusDays(1) = 2019-05-15 + // => IllegalArgumentException: 2019-05-15 < 2019-05-27 + if (recurringTransaction.getFirstOccurrence().isAfter(now)) { + LOGGER.debug(String.format("Recurring transaction %s has its first occurrence in the future and thus " + + "cannot be due today", + ReflectionToStringBuilder.toString(recurringTransaction))); + + return false; // early return + } + + final boolean holiday = this.ruleService.isHoliday(now); + + final boolean dueToday = recurringTransaction.getFirstOccurrence() + // This calculates all dates between the first occurrence of the + // recurring transaction and tomorrow for the interval specified + // by the recurring transaction. We need to use tomorrow as + // upper bound of the interval because the upper bound is exclusive + // in the datesUntil method. + .datesUntil(now.plusDays(1), this.ruleService + .getPeriodForInterval(recurringTransaction + .getIntervalType())) + // Then we check whether today is a date in the calculated range. + // If so the recurring transaction is due today + .anyMatch((d) -> d.equals(now)); + final boolean weekend = this.ruleService.isWeekend(now); + boolean defer = false; + + + if (holiday || weekend) { + defer = recurringTransaction.getHolidayWeekendType() == HolidayWeekendType.NEXT_WORKDAY + || recurringTransaction.getHolidayWeekendType() == HolidayWeekendType.PREVIOUS_WORKDAY; + } + + LOGGER.debug(String.format("Recurring transaction %s due today? %s (defer=%s, dueToday=%s)", + ReflectionToStringBuilder.toString(recurringTransaction), (!defer && dueToday), defer, dueToday)); + + return !defer && dueToday; + } + + /** + * This method checks whether the given {@link RecurringTransaction} was actually due in the close past but has been + * deferred to maybe today because the actual due day has been a holiday or weekend day and the {@link + * RecurringTransaction#getHolidayWeekendType() holiday weekend type} was {@link HolidayWeekendType#NEXT_WORKDAY}. + * Note that the recurring transaction may get deferred again if today again is a holiday or a weekend day. The + * period this method considers starts with today and ends with the last workday (no {@link + * RuleService#isHoliday(LocalDate) holiday}, not a {@link RuleService#isWeekend(LocalDate) weekend day}) whereas + * the end is exclusive, because if the recurring transaction would have been due at the last workday day it + * wouldn't has been deferred. + * + * @param recurringTransaction to check whether it is due today + * @param now today's date + * + * @return true if the recurring transaction is due today, false otherwise + */ + private boolean checkRecurringTransactionDuePast(RecurringTransaction recurringTransaction, LocalDate now) { + // Recurring transactions with holiday weekend type SAME_DAY or PREVIOUS_WORKDAY can't be due in the past + if (!HolidayWeekendType.NEXT_WORKDAY.equals(recurringTransaction.getHolidayWeekendType())) { + LOGGER.debug(String.format("Recurring transaction %s has HWT %s and thus cannot be due in the past", + ReflectionToStringBuilder.toString(recurringTransaction), + recurringTransaction.getHolidayWeekendType())); + + return false; // early return + } + + // If a recurring transactions first occurrence is in the future it can never be relevant for this + // method, as this method handles recurring transactions due in the past. + if (recurringTransaction.getFirstOccurrence().isAfter(now)) { + LOGGER.debug(String.format("Recurring transaction %s has its first occurrence in the future and thus " + + "cannot be due in the past", + ReflectionToStringBuilder.toString(recurringTransaction))); + + return false; // early return + } + + // If today is a weekend day or holiday the recurring transaction cannot be due today, because the + // holiday weekend type says NEXT_WORKDAY. + if (this.ruleService.isHoliday(now) || this.ruleService.isWeekend(now)) { + LOGGER.debug(String.format("Recurring transaction %s has HWT %s and today is either a holiday or weekend," + + " thus it cannot be due in the past", + ReflectionToStringBuilder.toString(recurringTransaction), + recurringTransaction.getHolidayWeekendType())); + + return false; // early return + } + + boolean weekend; + boolean holiday; + LocalDate yesterday = now; + boolean due = false; + + // Go back in time until we hit the first non-holiday, non-weekend day + // and check for every day in between if the given recurring transaction was due on this day + do { + yesterday = yesterday.minusDays(1); + holiday = this.ruleService.isHoliday(yesterday); + weekend = this.ruleService.isWeekend(yesterday); + + if (holiday || weekend) { + // Lambdas require final local variables + final LocalDate finalYesterday = yesterday; + + // For an explanation of the expression see the ...DueToday method + due = recurringTransaction.getFirstOccurrence() + .datesUntil(yesterday.plusDays(1), this.ruleService + .getPeriodForInterval(recurringTransaction + .getIntervalType())) + .anyMatch((d) -> d.equals(finalYesterday)); + + if (due) { + break; + } + } + } + while (holiday || weekend); + + LOGGER.debug(String.format("Recurring transaction %s is due in the past? %s", + ReflectionToStringBuilder.toString(recurringTransaction), due)); + + return due; + } + + /** + * This method checks whether the given {@link RecurringTransaction} will actually be due in the close future will + * be preponed to maybe today because the actual due day will be a holiday or weekend day and the {@link + * RecurringTransaction#getHolidayWeekendType() holiday weekend type} is {@link + * HolidayWeekendType#PREVIOUS_WORKDAY}. The period this method considers starts with today and ends with the next + * workday (no {@link RuleService#isHoliday(LocalDate) holiday}, not a {@link RuleService#isWeekend(LocalDate) + * weekend day}) whereas the end is exclusive, because if the recurring transaction will due at the next workday day + * it does not need to be preponed. + * + * @param recurringTransaction to check whether it is due today + * @param now today's date + * + * @return true if the recurring transaction is due today, false otherwise + */ + private boolean checkRecurringTransactionDueFuture(RecurringTransaction recurringTransaction, LocalDate now) { + // Recurring transactions with holiday weekend type SAME_DAY or PREVIOUS_WORKDAY can't be due in the future + if (!HolidayWeekendType.PREVIOUS_WORKDAY.equals(recurringTransaction.getHolidayWeekendType())) { + LOGGER.debug(String.format("Recurring transaction %s has HWT %s and thus cannot be due in the future", + ReflectionToStringBuilder.toString(recurringTransaction), + recurringTransaction.getHolidayWeekendType())); + + return false; // early return + } + + boolean weekend; + boolean holiday; + LocalDate tomorrow = now; + boolean due = false; + + // Go forth in time until we hit the first non-holiday, non-weekend day + // and check for every day in between if the given recurring transaction will be due on this day + do { + tomorrow = tomorrow.plusDays(1); + holiday = this.ruleService.isHoliday(tomorrow); + weekend = this.ruleService.isWeekend(tomorrow); + + if (holiday || weekend) { + // Lambdas require final local variables + final LocalDate finalTomorrow = tomorrow; + + // For an explanation of the expression see the ...DueToday method + due = recurringTransaction.getFirstOccurrence() + .datesUntil(tomorrow.plusDays(1), this.ruleService + .getPeriodForInterval(recurringTransaction + .getIntervalType())) + .anyMatch((d) -> d.equals(finalTomorrow)); + + if (due) { + break; + } + } + } + while (holiday || weekend); + + LOGGER.debug(String.format("Recurring transaction %s is due in the future? %s", + ReflectionToStringBuilder.toString(recurringTransaction), due)); + + return due; + } + + + + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason createRecurringTransaction(String fromAccountKey, String toAccountKey, Long amount, + String description, String holidayWeekendType, + String intervalType, String firstOccurrence, + String lastOccurrence, Boolean remind + ) { + final Account fromAccount = this.accountService.getAccountByKey(fromAccountKey); + final Account toAccount = this.accountService.getAccountByKey(toAccountKey); + ResponseReason response = validateParameters(fromAccount, toAccount, amount, holidayWeekendType, intervalType, + firstOccurrence, lastOccurrence); // no validation of 'remind' as it's completely optional + + // If we detected an issue with the given parameters return the first error found to the caller + if (response != null) { + return response; // early return + } + + try { + final RecurringTransaction transaction = buildRecurringTransaction(fromAccount, toAccount, amount, + description, holidayWeekendType, intervalType, firstOccurrence, lastOccurrence, remind); + + this.recurringTransactionRepository.save(transaction); + + response = ResponseReason.OK; + } catch (Exception e) { + LOGGER.error("Could not create recurring transaction!", e); + + response = ResponseReason.UNKNOWN_ERROR; + } + + return response; + } + + /** + * This method builds the actual recurring transaction object with the given values. + * + * @param fromAccount the from account + * @param toAccount the to account + * @param amount the transaction amount + * @param holidayWeekendType the holiday weekend type + * @param intervalType the interval type + * @param firstOccurrence the first occurrence + * @param lastOccurrence the last occurrence, may be null + * @param remind the remind flag + * + * @return the build {@link RecurringTransaction} instance + */ + private RecurringTransaction buildRecurringTransaction(Account fromAccount, Account toAccount, Long amount, + String description, String holidayWeekendType, + String intervalType, String firstOccurrence, + String lastOccurrence, Boolean remind + ) { + final RecurringTransaction recurringTransaction = new RecurringTransaction(); + + recurringTransaction.setFromAccount(fromAccount); + recurringTransaction.setToAccount(toAccount); + recurringTransaction.setAmount(amount); + recurringTransaction.setDescription(description); + recurringTransaction.setHolidayWeekendType(HolidayWeekendType.valueOf(holidayWeekendType)); + recurringTransaction.setIntervalType(IntervalType.valueOf(intervalType)); + recurringTransaction.setFirstOccurrence(LocalDate + .parse(firstOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()))); + // See 'resources/database/postgres/readme_V1_0_0__init.txt' + recurringTransaction.setDeleted(false); + recurringTransaction.setRemind(BooleanUtils.toBooleanDefaultIfNull(remind, true)); + + // lastOccurrence is optional + if (StringUtils.isNotEmpty(lastOccurrence)) { + recurringTransaction.setLastOccurrence(LocalDate + .parse(lastOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()))); + } + + return recurringTransaction; + } + + /** + * This method checks whether the parameters for creating a transaction are valid. + * + * @param fromAccount the from account + * @param toAccount the to account + * @param amount the transaction amount + * @param holidayWeekendType the holiday weekend type + * @param intervalType the interval type + * @param firstOccurrence the first occurrence + * @param lastOccurrence the last occurrence, may be null + * + * @return the first error found or null if all parameters are valid + */ + private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, + String holidayWeekendType, String intervalType, String firstOccurrence, + String lastOccurrence + ) { + ResponseReason response = null; + + if (fromAccount == null && toAccount == null) { + response = ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND; + } else if (toAccount == null) { + response = ResponseReason.TO_ACCOUNT_NOT_FOUND; + } else if (fromAccount == null) { + response = ResponseReason.FROM_ACCOUNT_NOT_FOUND; + } else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) { + response = ResponseReason.INVALID_BOOKING_ACCOUNTS; + } else if (amount == null) { + response = ResponseReason.MISSING_AMOUNT; + } else if (amount == 0L) { + response = ResponseReason.AMOUNT_ZERO; + } else if (StringUtils.isEmpty(holidayWeekendType)) { + response = ResponseReason.MISSING_HOLIDAY_WEEKEND_TYPE; + } else if (!HolidayWeekendType.isValidType(holidayWeekendType)) { + response = ResponseReason.INVALID_HOLIDAY_WEEKEND_TYPE; + } else if (StringUtils.isEmpty(intervalType)) { + response = ResponseReason.MISSING_INTERVAL_TYPE; + } else if (!IntervalType.isValidType(intervalType)) { + response = ResponseReason.INVALID_INTERVAL_TYPE; + } else if (StringUtils.isEmpty(firstOccurrence)) { + response = ResponseReason.MISSING_FIRST_OCCURRENCE; + } + + if (response == null && StringUtils.isNotEmpty(firstOccurrence)) { + try { + LocalDate.parse(firstOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())); + } catch (DateTimeParseException e) { + response = ResponseReason.INVALID_FIRST_OCCURRENCE_FORMAT; + } + } + + if (response == null && StringUtils.isNotEmpty(lastOccurrence)) { + try { + LocalDate.parse(lastOccurrence, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())); + } catch (DateTimeParseException e) { + response = ResponseReason.INVALID_LAST_OCCURRENCE_FORMAT; + } + } + + return response; + } + + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason createTransaction(String recurringTransactionId, Optional amount) { + if (recurringTransactionId == null) { + return ResponseReason.MISSING_RECURRING_TRANSACTION_ID; + } else if (!NumberUtils.isCreatable(recurringTransactionId)) { + return ResponseReason.INVALID_RECURRING_TRANSACTION_ID; + } + + final Optional 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 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; + } +} diff --git a/financer-server/src/main/java/de/financer/service/RuleService.java b/financer-server/src/main/java/de/financer/service/RuleService.java new file mode 100644 index 0000000..35436bc --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/RuleService.java @@ -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> bookingRules; + private Map intervalPeriods; + + @Override + public void afterPropertiesSet() { + initBookingRules(); + initIntervalValues(); + } + + private void initIntervalValues() { + this.intervalPeriods = new EnumMap<>(IntervalType.class); + + this.intervalPeriods.put(IntervalType.DAILY, Period.ofDays(1)); + this.intervalPeriods.put(IntervalType.WEEKLY, Period.ofWeeks(1)); + this.intervalPeriods.put(IntervalType.MONTHLY, Period.ofMonths(1)); + this.intervalPeriods.put(IntervalType.QUARTERLY, Period.ofMonths(3)); + this.intervalPeriods.put(IntervalType.YEARLY, Period.ofYears(1)); + } + + private void initBookingRules() { + 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. + *

+ * The multiplier controls whether the current amount of the given from account is increased or decreased depending + * on the {@link AccountType} of the given account. + * + * @param fromAccount the from account to get the multiplier for + * + * @return the multiplier, either 1 or -1 + */ + public long getMultiplierFromAccount(Account fromAccount) { + // There is no multiplier if the from account is an EXPENSE account because + // it's not a valid from account type + + final AccountType accountType = fromAccount.getType(); + + if (INCOME.equals(accountType)) { + return 1L; + } else if (BANK.equals(accountType)) { + return -1L; + } else if (CASH.equals(accountType)) { + return -1L; + } else if (LIABILITY.equals(accountType)) { + return 1L; + } else if (START.equals(accountType)) { + return 1L; + } + + LOGGER.warn(String + .format("Unknown or invalid account type in getMultiplierFromAccount: %s", accountType.name())); + + return 1L; + } + + /** + * This method returns the multiplier for the given to account. + *

+ * The multiplier controls whether the current amount of the given to account is increased or decreased depending on + * the {@link AccountType} of the given account. + * + * @param toAccount the to account to get the multiplier for + * + * @return the multiplier, either 1 or -1 + */ + public long getMultiplierToAccount(Account toAccount) { + // There are no multipliers for INCOME and START accounts + // because they are not valid to account types + + final AccountType accountType = toAccount.getType(); + + if (BANK.equals(accountType)) { + return 1L; + } else if (CASH.equals(accountType)) { + return 1L; + } else if (LIABILITY.equals(accountType)) { + return -1L; + } else if (EXPENSE.equals(accountType)) { + return 1L; + } + + LOGGER.warn(String + .format("Unknown or invalid account type in getMultiplierToAccount: %s", accountType.name())); + + return -1L; + } + + /** + * This method validates whether the booking from fromAccount to toAccount is valid, e.g. + * booking directly from an {@link AccountType#INCOME INCOME} to an {@link AccountType#EXPENSE EXPENSE} account does + * not make sense and is declared as invalid. + * + * @param fromAccount the account to subtract the money from + * @param toAccount the account to add the money to + * + * @return true if the from->to relationship of the given accounts is valid, false + * otherwise + */ + public boolean isValidBooking(Account fromAccount, Account toAccount) { + return this.bookingRules.get(fromAccount.getType()).contains(toAccount.getType()); + } + + /** + * This method gets the {@link Period} for the given {@link IntervalType}, e.g. a period of three months for {@link + * IntervalType#QUARTERLY}. + * + * @param intervalType to get the period for + * + * @return the period matching the interval type + */ + public Period getPeriodForInterval(IntervalType intervalType) { + return this.intervalPeriods.get(intervalType); + } + + /** + * This method checks whether the given date is a holiday in the configured country and state. + * + * @param now the date to check + * + * @return true if the given date is a holiday, false otherwise + */ + public boolean isHoliday(LocalDate now) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("Use state '%s' for holiday calculation", this.financerConfig.getState())); + } + + return HolidayManager.getInstance(ManagerParameters.create(this.financerConfig.getHolidayCalendar())) + .isHoliday(now, this.financerConfig.getState()); + } + + /** + * This method checks whether the given date is a weekend day, i.e. whether it's a {@link DayOfWeek#SATURDAY} or + * {@link DayOfWeek#SUNDAY}. + * + * @param now the date to check + * + * @return true if the given date is a weekend day, false otherwise + */ + public boolean isWeekend(LocalDate now) { + return EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(now.getDayOfWeek()); + } +} diff --git a/financer-server/src/main/java/de/financer/service/TransactionService.java b/financer-server/src/main/java/de/financer/service/TransactionService.java new file mode 100644 index 0000000..1cfa3cc --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/TransactionService.java @@ -0,0 +1,228 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.config.FinancerConfig; +import de.financer.dba.TransactionRepository; +import de.financer.model.Account; +import de.financer.model.AccountType; +import de.financer.model.RecurringTransaction; +import de.financer.model.Transaction; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.Optional; + +@Service +public class TransactionService { + private static final Logger LOGGER = LoggerFactory.getLogger(TransactionService.class); + + @Autowired + private AccountService accountService; + + @Autowired + private RuleService ruleService; + + @Autowired + private TransactionRepository transactionRepository; + + @Autowired + private FinancerConfig financerConfig; + + /** + * @return all transactions, for all accounts and all time + */ + public Iterable 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 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 + * null + * + * @return the build {@link Transaction} instance + */ + private Transaction buildTransaction(Account fromAccount, Account toAccount, Long amount, String description, + String date, RecurringTransaction recurringTransaction + ) { + final Transaction transaction = new Transaction(); + + transaction.setFromAccount(fromAccount); + transaction.setToAccount(toAccount); + transaction.setAmount(amount); + transaction.setDescription(description); + transaction.setDate(LocalDate.parse(date, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat()))); + transaction.setRecurringTransaction(recurringTransaction); + + return transaction; + } + + /** + * This method checks whether the parameters for creating a transaction are valid. + * + * @param fromAccount the from account + * @param toAccount the to account + * @param amount the transaction amount + * @param date the transaction date + * + * @return the first error found or null if all parameters are valid + */ + private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, String date) { + ResponseReason response = null; + + if (fromAccount == null && toAccount == null) { + response = ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND; + } else if (toAccount == null) { + response = ResponseReason.TO_ACCOUNT_NOT_FOUND; + } else if (fromAccount == null) { + response = ResponseReason.FROM_ACCOUNT_NOT_FOUND; + } else if (!this.ruleService.isValidBooking(fromAccount, toAccount)) { + response = ResponseReason.INVALID_BOOKING_ACCOUNTS; + } else if (amount == null) { + response = ResponseReason.MISSING_AMOUNT; + } else if (amount == 0L) { + response = ResponseReason.AMOUNT_ZERO; + } else if (StringUtils.isEmpty(date)) { + response = ResponseReason.MISSING_DATE; + } else if (StringUtils.isNotEmpty(date)) { + try { + LocalDate.parse(date, DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())); + } catch (DateTimeParseException e) { + response = ResponseReason.INVALID_DATE_FORMAT; + } + } + + return response; + } + + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason deleteTransaction(String transactionId) { + ResponseReason response = ResponseReason.OK; + + if (transactionId == null) { + return ResponseReason.MISSING_TRANSACTION_ID; + } else if (!NumberUtils.isCreatable(transactionId)) { + return ResponseReason.INVALID_TRANSACTION_ID; + } + + final Optional 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; + } +} diff --git a/financer-server/src/main/java/de/financer/service/package-info.java b/financer-server/src/main/java/de/financer/service/package-info.java new file mode 100644 index 0000000..d440c80 --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/package-info.java @@ -0,0 +1,9 @@ +/** + *

+ * This package contains the actual business logic services of the financer application. + * They get called by the {@link de.financer.controller controller} layer. Also they call each other, + * e.g. the {@link de.financer.service.TransactionService} internally uses the {@link de.financer.service.RuleService}. + * Data access is done via the {@link de.financer.dba DBA} layer. + *

+ */ +package de.financer.service; \ No newline at end of file diff --git a/financer-server/src/main/java/de/financer/task/SendRecurringTransactionReminderTask.java b/financer-server/src/main/java/de/financer/task/SendRecurringTransactionReminderTask.java new file mode 100644 index 0000000..48cf729 --- /dev/null +++ b/financer-server/src/main/java/de/financer/task/SendRecurringTransactionReminderTask.java @@ -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 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()); + } + } +} diff --git a/financer-server/src/main/resources/config/application-dev.properties b/financer-server/src/main/resources/config/application-dev.properties new file mode 100644 index 0000000..000f956 --- /dev/null +++ b/financer-server/src/main/resources/config/application-dev.properties @@ -0,0 +1,2 @@ +# Hibernate +spring.jpa.show-sql=true \ No newline at end of file diff --git a/financer-server/src/main/resources/config/application-hsqldb.properties b/financer-server/src/main/resources/config/application-hsqldb.properties new file mode 100644 index 0000000..3976d92 --- /dev/null +++ b/financer-server/src/main/resources/config/application-hsqldb.properties @@ -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 \ No newline at end of file diff --git a/financer-server/src/main/resources/config/application-postgres.properties b/financer-server/src/main/resources/config/application-postgres.properties new file mode 100644 index 0000000..1eeea1c --- /dev/null +++ b/financer-server/src/main/resources/config/application-postgres.properties @@ -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 \ No newline at end of file diff --git a/financer-server/src/main/resources/config/application.properties b/financer-server/src/main/resources/config/application.properties new file mode 100644 index 0000000..bc847d2 --- /dev/null +++ b/financer-server/src/main/resources/config/application.properties @@ -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 \ No newline at end of file diff --git a/financer-server/src/main/resources/database/common/V1_0_1__initData.sql b/financer-server/src/main/resources/database/common/V1_0_1__initData.sql new file mode 100644 index 0000000..e675048 --- /dev/null +++ b/financer-server/src/main/resources/database/common/V1_0_1__initData.sql @@ -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); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/common/V3_0_0__accountRename.sql b/financer-server/src/main/resources/database/common/V3_0_0__accountRename.sql new file mode 100644 index 0000000..4bdd4e8 --- /dev/null +++ b/financer-server/src/main/resources/database/common/V3_0_0__accountRename.sql @@ -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'; \ No newline at end of file diff --git a/financer-server/src/main/resources/database/common/V7_0_1__initAccountGroups.sql b/financer-server/src/main/resources/database/common/V7_0_1__initAccountGroups.sql new file mode 100644 index 0000000..ac3fa07 --- /dev/null +++ b/financer-server/src/main/resources/database/common/V7_0_1__initAccountGroups.sql @@ -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'); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/hsqldb/V1_0_0__init.sql b/financer-server/src/main/resources/database/hsqldb/V1_0_0__init.sql new file mode 100644 index 0000000..b1a900b --- /dev/null +++ b/financer-server/src/main/resources/database/hsqldb/V1_0_0__init.sql @@ -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) +); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/hsqldb/V6_0_0__remindFlagRecurringTransaction.sql b/financer-server/src/main/resources/database/hsqldb/V6_0_0__remindFlagRecurringTransaction.sql new file mode 100644 index 0000000..a9410ad --- /dev/null +++ b/financer-server/src/main/resources/database/hsqldb/V6_0_0__remindFlagRecurringTransaction.sql @@ -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; \ No newline at end of file diff --git a/financer-server/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql b/financer-server/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql new file mode 100644 index 0000000..769184b --- /dev/null +++ b/financer-server/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql @@ -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); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/postgres/V1_0_0__init.sql b/financer-server/src/main/resources/database/postgres/V1_0_0__init.sql new file mode 100644 index 0000000..5d6221e --- /dev/null +++ b/financer-server/src/main/resources/database/postgres/V1_0_0__init.sql @@ -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) +); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/postgres/V6_0_0__remindFlagRecurringTransaction.sql b/financer-server/src/main/resources/database/postgres/V6_0_0__remindFlagRecurringTransaction.sql new file mode 100644 index 0000000..aa29189 --- /dev/null +++ b/financer-server/src/main/resources/database/postgres/V6_0_0__remindFlagRecurringTransaction.sql @@ -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 \ No newline at end of file diff --git a/financer-server/src/main/resources/database/postgres/V7_0_0__accountGroup.sql b/financer-server/src/main/resources/database/postgres/V7_0_0__accountGroup.sql new file mode 100644 index 0000000..5d34e0f --- /dev/null +++ b/financer-server/src/main/resources/database/postgres/V7_0_0__accountGroup.sql @@ -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); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/postgres/readme_V1_0_0__init.txt b/financer-server/src/main/resources/database/postgres/readme_V1_0_0__init.txt new file mode 100644 index 0000000..8d853b0 --- /dev/null +++ b/financer-server/src/main/resources/database/postgres/readme_V1_0_0__init.txt @@ -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() \ No newline at end of file diff --git a/financer-server/src/test/java/de/financer/FinancerApplicationBootTest.java b/financer-server/src/test/java/de/financer/FinancerApplicationBootTest.java new file mode 100644 index 0000000..21ec49f --- /dev/null +++ b/financer-server/src/test/java/de/financer/FinancerApplicationBootTest.java @@ -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); + } +} diff --git a/financer-server/src/test/java/de/financer/controller/integration/AccountController_getAllIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/AccountController_getAllIntegrationTest.java new file mode 100644 index 0000000..082d37f --- /dev/null +++ b/financer-server/src/test/java/de/financer/controller/integration/AccountController_getAllIntegrationTest.java @@ -0,0 +1,49 @@ +package de.financer.controller.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.financer.FinancerApplication; +import de.financer.model.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 allAccounts = this.objectMapper + .readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>() {}); + + Assert.assertEquals(23, allAccounts.size()); + } +} diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java new file mode 100644 index 0000000..1b7cf20 --- /dev/null +++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createRecurringTransactionIntegrationTest.java @@ -0,0 +1,60 @@ +package de.financer.controller.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.financer.FinancerApplication; +import de.financer.model.RecurringTransaction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = FinancerApplication.class) +@AutoConfigureMockMvc +@TestPropertySource( + locations = "classpath:application-integrationtest.properties") +public class RecurringTransactionService_createRecurringTransactionIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test_createRecurringTransaction() throws Exception { + final MvcResult mvcRequest = this.mockMvc.perform(get("/recurringTransactions/createRecurringTransaction") + .param("fromAccountKey", "Income") + .param("toAccountKey", "Check account") + .param("amount", "250000") + .param("description", "Monthly rent") + .param("holidayWeekendType", "SAME_DAY") + .param("intervalType", "MONTHLY") + .param("firstOccurrence", "07.03.2019") + .param("remind", "true")) + .andExpect(status().isOk()) + .andReturn(); + + final MvcResult mvcResult = this.mockMvc.perform(get("/recurringTransactions/getAll") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + final List allRecurringTransaction = this.objectMapper + .readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>() {}); + + Assert.assertEquals(4, allRecurringTransaction.size()); + } +} diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java new file mode 100644 index 0000000..c19d396 --- /dev/null +++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_createTransactionIntegrationTest.java @@ -0,0 +1,68 @@ +package de.financer.controller.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.financer.FinancerApplication; +import de.financer.model.RecurringTransaction; +import de.financer.model.Transaction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.Optional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = FinancerApplication.class) +@AutoConfigureMockMvc +@TestPropertySource( + locations = "classpath:application-integrationtest.properties") +public class RecurringTransactionService_createTransactionIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test_createTransaction() throws Exception { + final MvcResult mvcResultAll = this.mockMvc.perform(get("/recurringTransactions/getAll") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + final List allRecurringTransactions = this.objectMapper + .readValue(mvcResultAll.getResponse().getContentAsByteArray(), new TypeReference>() {}); + final Optional 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 allTransactions = this.objectMapper + .readValue(mvcResultAllTransactions.getResponse().getContentAsByteArray(), new TypeReference>() {}); + + Assert.assertEquals(1, allTransactions.size()); + } +} diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java new file mode 100644 index 0000000..f170a6f --- /dev/null +++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllActiveIntegrationTest.java @@ -0,0 +1,49 @@ +package de.financer.controller.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.financer.FinancerApplication; +import de.financer.model.RecurringTransaction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = FinancerApplication.class) +@AutoConfigureMockMvc +@TestPropertySource( + locations = "classpath:application-integrationtest.properties") +public class RecurringTransactionService_getAllActiveIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test_getAll() throws Exception { + final MvcResult mvcResult = this.mockMvc + .perform(get("/recurringTransactions/getAllActive").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + final List allRecurringTransactions = this.objectMapper + .readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>() {}); + + Assert.assertEquals(3, allRecurringTransactions.size()); + } + +} diff --git a/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java new file mode 100644 index 0000000..abea31f --- /dev/null +++ b/financer-server/src/test/java/de/financer/controller/integration/RecurringTransactionService_getAllIntegrationTest.java @@ -0,0 +1,49 @@ +package de.financer.controller.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.financer.FinancerApplication; +import de.financer.model.RecurringTransaction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = FinancerApplication.class) +@AutoConfigureMockMvc +@TestPropertySource( + locations = "classpath:application-integrationtest.properties") +public class RecurringTransactionService_getAllIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test_getAll() throws Exception { + final MvcResult mvcResult = this.mockMvc + .perform(get("/recurringTransactions/getAll").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + final List allRecurringTransactions = this.objectMapper + .readValue(mvcResult.getResponse().getContentAsByteArray(), new TypeReference>() {}); + + Assert.assertEquals(4, allRecurringTransactions.size()); + } + +} diff --git a/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java b/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java new file mode 100644 index 0000000..9f097db --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java @@ -0,0 +1,62 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountGroupRepository; +import de.financer.model.AccountGroup; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.dao.DataIntegrityViolationException; + +@RunWith(MockitoJUnitRunner.class) +public class AccountGroupService_createAccountGroupTest { + + @InjectMocks + private AccountGroupService classUnderTest; + + @Mock + private AccountGroupRepository accountGroupRepository; + + @Test + public void test_createAccount_UNKNOWN_ERROR() { + // Arrange + Mockito.doThrow(new NullPointerException()).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_createAccount_OK() { + // Arrange + // Nothing to do + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + Mockito.verify(this.accountGroupRepository, Mockito.times(1)) + .save(ArgumentMatchers.argThat((ag) -> "Test".equals(ag.getName()))); + } + + @Test + public void test_createAccount_DUPLICATE_ACCOUNT_GROUP_NAME() { + // Arrange + Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME, response); + } +} diff --git a/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java b/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java new file mode 100644 index 0000000..400b77a --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/AccountService_createAccountTest.java @@ -0,0 +1,90 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountRepository; +import de.financer.model.Account; +import de.financer.model.AccountGroup; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.dao.DataIntegrityViolationException; + +@RunWith(MockitoJUnitRunner.class) +public class AccountService_createAccountTest { + @InjectMocks + private AccountService classUnderTest; + + @Mock + private AccountGroupService accountGroupService; + + @Mock + private AccountRepository accountRepository; + + @Test + public void test_createAccount_INVALID_ACCOUNT_TYPE() { + // Arrange + // Nothing to do + + // Act + ResponseReason response = this.classUnderTest.createAccount(null, null, null); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_ACCOUNT_TYPE, response); + } + + @Test + public void test_createAccount_UNKNOWN_ERROR() { + // Arrange + Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_createAccount_OK() { + // Arrange + // Nothing to do + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + Mockito.verify(this.accountRepository, Mockito.times(1)) + .save(ArgumentMatchers.argThat((acc) -> "Test".equals(acc.getKey()))); + } + + @Test + public void test_createAccount_ACCOUNT_GROUP_NOT_FOUND() { + // Arrange + Mockito.when(this.accountGroupService.getAccountGroupByName(Mockito.anyString())) + .thenReturn(null); + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", "Group1"); + + // Assert + Assert.assertEquals(ResponseReason.ACCOUNT_GROUP_NOT_FOUND, response); + } + + @Test + public void test_createAccount_DUPLICATE_ACCOUNT_KEY() { + // Arrange + Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountRepository).save(Mockito.any(Account.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); + + // Assert + Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_KEY, response); + } +} diff --git a/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java b/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java new file mode 100644 index 0000000..6c0ce2c --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/AccountService_setAccountStatusTest.java @@ -0,0 +1,62 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountRepository; +import de.financer.model.Account; +import de.financer.model.AccountStatus; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class AccountService_setAccountStatusTest { + @InjectMocks + private AccountService classUnderTest; + + @Mock + private AccountRepository accountRepository; + + @Test + public void test_setAccountStatus_ACCOUNT_NOT_FOUND() { + // Arrange + // Nothing to do + + // Act + ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED); + + // Assert + Assert.assertEquals(ResponseReason.ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_setAccountStatus_UNKNOWN_ERROR() { + // Arrange + Mockito.when(this.accountRepository.findByKey(Mockito.anyString())).thenReturn(new Account()); + Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class)); + + // Act + ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_setAccountStatus_OK() { + // Arrange + Mockito.when(this.accountRepository.findByKey(Mockito.anyString())).thenReturn(new Account()); + + // Act + ResponseReason response = this.classUnderTest.setAccountStatus("Test", AccountStatus.CLOSED); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + Mockito.verify(this.accountRepository, Mockito.times(1)) + .save(ArgumentMatchers.argThat((acc) -> AccountStatus.CLOSED.equals(acc.getStatus()))); + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java new file mode 100644 index 0000000..c34cf0d --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_createRecurringTransactionTest.java @@ -0,0 +1,361 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.config.FinancerConfig; +import de.financer.dba.RecurringTransactionRepository; +import de.financer.model.Account; +import de.financer.model.HolidayWeekendType; +import de.financer.model.IntervalType; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class RecurringTransactionService_createRecurringTransactionTest { + @InjectMocks + private RecurringTransactionService classUnderTest; + + @Mock + private AccountService accountService; + + @Mock + private RuleService ruleService; + + @Mock + private RecurringTransactionRepository recurringTransactionRepository; + + @Mock + private FinancerConfig financerConfig; + + @Before + public void setUp() { + Mockito.when(this.financerConfig.getDateFormat()).thenReturn("dd.MM.yyyy"); + } + + @Test + public void test_createRecurringTransaction_FROM_AND_TO_ACCOUNT_NOT_FOUND() { + // Arrange + // Nothing to do, if we do not instruct the account service instance to return anything the accounts + // will not be found. + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid", + "account.invalid", + Long.valueOf(150l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createRecurringTransaction_TO_ACCOUNT_NOT_FOUND() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), null); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.invalid", + Long.valueOf(150l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.TO_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createRecurringTransaction_FROM_ACCOUNT_NOT_FOUND() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(null, createAccount()); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.FROM_ACCOUNT_NOT_FOUND, response); + } + + @Test + public void test_createRecurringTransaction_INVALID_BOOKING_ACCOUNTS() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.FALSE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_BOOKING_ACCOUNTS, response); + } + + @Test + public void test_createRecurringTransaction_MISSING_AMOUNT() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + null, + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_AMOUNT, response); + } + + @Test + public void test_createRecurringTransaction_AMOUNT_ZERO() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(0l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.AMOUNT_ZERO, response); + } + + @Test + public void test_createRecurringTransaction_MISSING_HOLIDAY_WEEKEND_TYPE() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + null, + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_HOLIDAY_WEEKEND_TYPE, response); + } + + @Test + public void test_createRecurringTransaction_INVALID_HOLIDAY_WEEKEND_TYPE() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + "HOLIDAY_WEEKEND_TYPE", + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_HOLIDAY_WEEKEND_TYPE, response); + } + + @Test + public void test_createRecurringTransaction_MISSING_INTERVAL_TYPE() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + null, + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_INTERVAL_TYPE, response); + } + + @Test + public void test_createRecurringTransaction_INVALID_INTERVAL_TYPE() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + "INTERVAL_TYPE", + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_INTERVAL_TYPE, response); + } + + @Test + public void test_createRecurringTransaction_MISSING_FIRST_OCCURRENCE() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + IntervalType.DAILY.name(), + null, + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_FIRST_OCCURRENCE, response); + } + + @Test + public void test_createRecurringTransaction_INVALID_FIRST_OCCURRENCE_FORMAT() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + IntervalType.DAILY.name(), + "FIRST_OCCURRENCE", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_FIRST_OCCURRENCE_FORMAT, response); + } + + @Test + public void test_createRecurringTransaction_INVALID_LAST_OCCURRENCE_FORMAT() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + IntervalType.DAILY.name(), + "07.03.2019", + "LAST_OCCURRENCE", + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_LAST_OCCURRENCE_FORMAT, response); + } + + @Test + public void test_createRecurringTransaction_UNKNOWN_ERROR() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + Mockito.when(this.recurringTransactionRepository.save(Mockito.any())).thenThrow(new NullPointerException()); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + IntervalType.DAILY.name(), + "07.03.2019", + null, + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_createRecurringTransaction_OK() { + // Arrange + Mockito.when(this.accountService.getAccountByKey(Mockito.anyString())).thenReturn(createAccount(), createAccount()); + Mockito.when(this.ruleService.isValidBooking(Mockito.any(Account.class), Mockito.any(Account.class))).thenReturn(Boolean.TRUE); + + // Act + final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from", + "account.to", + Long.valueOf(150l), + "DESCRIPTION", + HolidayWeekendType.SAME_DAY.name(), + IntervalType.DAILY.name(), + "07.03.2019", + null, + Boolean.TRUE); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + } + + private Account createAccount() { + final Account account = new Account(); + + account.setCurrentBalance(Long.valueOf(0l)); + + return account; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java new file mode 100644 index 0000000..8a44a1c --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_deleteRecurringTransactionTest.java @@ -0,0 +1,94 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.config.FinancerConfig; +import de.financer.dba.RecurringTransactionRepository; +import de.financer.model.RecurringTransaction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Optional; + +@RunWith(MockitoJUnitRunner.class) +public class RecurringTransactionService_deleteRecurringTransactionTest { + @InjectMocks + private RecurringTransactionService classUnderTest; + + @Mock + private AccountService accountService; + + @Mock + private RuleService ruleService; + + @Mock + private RecurringTransactionRepository recurringTransactionRepository; + + @Mock + private FinancerConfig financerConfig; + + @Test + public void test_deleteRecurringTransaction_MISSING_RECURRING_TRANSACTION_ID() { + // Arrange + // Nothing to do + + // Act + final ResponseReason response = this.classUnderTest.deleteRecurringTransaction(null); + + // Assert + Assert.assertEquals(ResponseReason.MISSING_RECURRING_TRANSACTION_ID, response); + } + + @Test + public void test_deleteRecurringTransaction_INVALID_RECURRING_TRANSACTION_ID() { + // Arrange + // Nothing to do + + // Act + final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("invalid"); + + // Assert + Assert.assertEquals(ResponseReason.INVALID_RECURRING_TRANSACTION_ID, response); + } + + @Test + public void test_deleteRecurringTransaction_RECURRING_TRANSACTION_NOT_FOUND() { + // Arrange + Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.empty()); + + // Act + final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123"); + + // Assert + Assert.assertEquals(ResponseReason.RECURRING_TRANSACTION_NOT_FOUND, response); + } + + @Test + public void test_deleteRecurringTransaction_UNKNOWN_ERROR() { + // Arrange + Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.of(new RecurringTransaction())); + Mockito.doThrow(new NullPointerException()).when(this.recurringTransactionRepository).save(Mockito.any()); + + // Act + final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123"); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_deleteRecurringTransaction_OK() { + // Arrange + Mockito.when(this.recurringTransactionRepository.findById(Mockito.anyLong())).thenReturn(Optional.of(new RecurringTransaction())); + + // Act + final ResponseReason response = this.classUnderTest.deleteRecurringTransaction("123"); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java new file mode 100644 index 0000000..0471644 --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest.java @@ -0,0 +1,143 @@ +package de.financer.service; + +import de.financer.dba.RecurringTransactionRepository; +import de.financer.model.HolidayWeekendType; +import de.financer.model.IntervalType; +import de.financer.model.RecurringTransaction; +import org.apache.commons.collections4.IterableUtils; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.time.LocalDate; +import java.time.Period; +import java.util.Collections; + +/** + * This class contains tests for the {@link RecurringTransactionService}, specifically for {@link RecurringTransaction}s + * that have {@link IntervalType#DAILY} and {@link HolidayWeekendType#NEXT_WORKDAY}. Due to these restrictions this + * class does not contain any tests for recurring transactions due in the close past that have been deferred, because + * recurring transactions with interval type daily get executed on the next workday anyway, regardless whether they have + * been deferred. This means that some executions of a recurring transaction with daily/next workday get ignored if they + * are on a holiday or a weekend day - they do not get executed multiple times on the next workday. While this is + * somehow unfortunate it is not in the current requirements and therefore left out for the sake of simplicity. + * If such a behavior is required daily/same day should do the trick, even though with slightly different semantics + * (execution even on holidays or weekends). + */ +@RunWith(MockitoJUnitRunner.class) +public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest { + @InjectMocks + private RecurringTransactionService classUnderTest; + + @Mock + private RecurringTransactionRepository recurringTransactionRepository; + + @Mock + private RuleService ruleService; + + @Before + public void setUp() { + Mockito.when(this.ruleService.getPeriodForInterval(IntervalType.DAILY)).thenReturn(Period.ofDays(1)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = three days ago, intervalType = daily and + * holidayWeekendType = next_workday is due on a non-holiday, non-weekend day + */ + @Test + public void test_getAllDueToday_dueToday() { + // Arrange + // Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false) + Mockito.when(this.recurringTransactionRepository + .findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any())) + .thenReturn(Collections.singletonList(createRecurringTransaction(-3))); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(1, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = today, intervalType = daily and + * holidayWeekendType = next_workday is not due on a holiday, non-weekend day + */ + @Test + public void test_getAllDueToday_dueToday_holiday() { + // Arrange + // Implicitly: ruleService.isWeekend().return(false) + Mockito.when(this.recurringTransactionRepository + .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 recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = today, intervalType = daily and + * holidayWeekendType = next_workday is not due on a non-holiday, weekend day + */ + @Test + public void test_getAllDueToday_dueToday_weekend() { + // Arrange + // Implicitly: ruleService.isHoliday().return(false) + Mockito.when(this.recurringTransactionRepository + .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 recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + /** + * This method tests whether a recurring transaction with firstOccurrence = tomorrow, intervalType = daily and + * holidayWeekendType = next_workday is not due today + */ + @Test + public void test_getAllDueToday_dueToday_tomorrow() { + // Arrange + // Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false) + Mockito.when(this.recurringTransactionRepository + .findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any())) + .thenReturn(Collections.singletonList(createRecurringTransaction(1))); + final LocalDate now = LocalDate.now(); + + // Act + final Iterable recurringDueToday = this.classUnderTest.getAllDueToday(now); + + // Assert + Assert.assertEquals(0, IterableUtils.size(recurringDueToday)); + } + + private RecurringTransaction createRecurringTransaction(int days) { + final RecurringTransaction recurringTransaction = new RecurringTransaction(); + + recurringTransaction.setFirstOccurrence(LocalDate.now().plusDays(days)); + + recurringTransaction.setHolidayWeekendType(HolidayWeekendType.NEXT_WORKDAY); + recurringTransaction.setIntervalType(IntervalType.DAILY); + recurringTransaction.setDeleted(false); + + return recurringTransaction; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java new file mode 100644 index 0000000..83733ba --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest.java @@ -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 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 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 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 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 not 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 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 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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAYTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAYTest.java new file mode 100644 index 0000000..cc8609e --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAYTest.java @@ -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 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 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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_SAME_DAYTest.java b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_SAME_DAYTest.java new file mode 100644 index 0000000..e04ef6a --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RecurringTransactionService_getAllDueToday_MONTHLY_SAME_DAYTest.java @@ -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 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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierFromAccountTest.java b/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierFromAccountTest.java new file mode 100644 index 0000000..d2ddc3a --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierFromAccountTest.java @@ -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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierToAccountTest.java b/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierToAccountTest.java new file mode 100644 index 0000000..3224e30 --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RuleService_getMultiplierToAccountTest.java @@ -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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/RuleService_isValidBookingTest.java b/financer-server/src/test/java/de/financer/service/RuleService_isValidBookingTest.java new file mode 100644 index 0000000..4fb39fa --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/RuleService_isValidBookingTest.java @@ -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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java b/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java new file mode 100644 index 0000000..24ca33d --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java @@ -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; + } +} diff --git a/financer-server/src/test/java/de/financer/service/TransactionService_deleteTransactionTest.java b/financer-server/src/test/java/de/financer/service/TransactionService_deleteTransactionTest.java new file mode 100644 index 0000000..f6febd5 --- /dev/null +++ b/financer-server/src/test/java/de/financer/service/TransactionService_deleteTransactionTest.java @@ -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; + } +} diff --git a/financer-server/src/test/java/de/financer/task/SendRecurringTransactionReminderTaskTest.java b/financer-server/src/test/java/de/financer/task/SendRecurringTransactionReminderTaskTest.java new file mode 100644 index 0000000..215f2fc --- /dev/null +++ b/financer-server/src/test/java/de/financer/task/SendRecurringTransactionReminderTaskTest.java @@ -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 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; + } +} diff --git a/financer-server/src/test/resources/application-integrationtest.properties b/financer-server/src/test/resources/application-integrationtest.properties new file mode 100644 index 0000000..d6974b3 --- /dev/null +++ b/financer-server/src/test/resources/application-integrationtest.properties @@ -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 diff --git a/financer-server/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql b/financer-server/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql new file mode 100644 index 0000000..ef84e78 --- /dev/null +++ b/financer-server/src/test/resources/database/hsqldb/integration/V999_99_00__testdata.sql @@ -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'); \ No newline at end of file