Add FQL and rework /transaction endpoint

This commit is contained in:
2020-03-14 23:44:43 +01:00
parent 1cc7fdf052
commit d4fef75903
85 changed files with 2257 additions and 472 deletions

View File

@@ -56,6 +56,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity;
public enum ResponseReason {
OK(HttpStatus.OK),
CREATED(HttpStatus.CREATED),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_ACCOUNT_TYPE(HttpStatus.INTERNAL_SERVER_ERROR),
FROM_ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
@@ -31,9 +32,15 @@ public enum ResponseReason {
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);
ACCOUNT_GROUP_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
PERIOD_ID_NOT_NUMERIC(HttpStatus.BAD_REQUEST),
PERIOD_NOT_FOUND(HttpStatus.BAD_REQUEST),
LIMIT_NOT_NUMERIC(HttpStatus.BAD_REQUEST),
INVALID_TAX_RELEVANT_VALUE(HttpStatus.BAD_REQUEST),
INVALID_ACCOUNTS_AND_VALUE(HttpStatus.BAD_REQUEST),
FQL_MALFORMED(HttpStatus.BAD_REQUEST);
private HttpStatus httpStatus;
private final HttpStatus httpStatus;
ResponseReason(HttpStatus httpStatus) {
this.httpStatus = httpStatus;
@@ -43,6 +50,10 @@ public enum ResponseReason {
return new ResponseEntity<>(this.name(), this.httpStatus);
}
public HttpStatus getHttpStatus() {
return this.httpStatus;
}
public static ResponseReason fromResponseEntity(ResponseEntity<String> entity) {
for (ResponseReason reason : values()) {
if (reason.name().equals(entity.getBody())) {

View File

@@ -0,0 +1,30 @@
package de.financer.dto;
import de.financer.dto.comparator.TransactionsByDateByIdDescComparator;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Optional;
public enum Order {
TRANSACTIONS_BY_DATE_DESC(TransactionsByDateByIdDescComparator.class);
private Comparator comparator;
Order(Class<? extends Comparator> comparatorClass) {
try {
this.comparator = comparatorClass.getDeclaredConstructor().newInstance();
}
catch (ReflectiveOperationException e) {
// Comparators are required by contract to have a default constructor
}
}
public Comparator getComparator() {
return this.comparator;
}
public static Optional<Order> getByName(String name) {
return Arrays.stream(values()).filter(o -> o.name().equals(name)).findFirst();
}
}

View File

@@ -0,0 +1,65 @@
package de.financer.dto;
import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
public class SaveTransactionRequestDto {
private String fromAccountKey;
private String toAccountKey;
private String amount;
private String date;
private String description;
private Boolean taxRelevant;
public String getFromAccountKey() {
return fromAccountKey;
}
public void setFromAccountKey(String fromAccountKey) {
this.fromAccountKey = fromAccountKey;
}
public String getToAccountKey() {
return toAccountKey;
}
public void setToAccountKey(String toAccountKey) {
this.toAccountKey = toAccountKey;
}
public String getAmount() {
return amount;
}
public void setAmount(String amount) {
this.amount = amount;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Boolean getTaxRelevant() {
return taxRelevant;
}
public void setTaxRelevant(Boolean taxRelevant) {
this.taxRelevant = taxRelevant;
}
@Override
public String toString() {
return ReflectionToStringBuilder.toString(this);
}
}

View File

@@ -0,0 +1,65 @@
package de.financer.dto;
import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
public class SearchTransactionsRequestDto {
private String fromAccountKey;
private String toAccountKey;
private String periodId;
private String limit;
private String order;
private String taxRelevant;
public String getFromAccountKey() {
return fromAccountKey;
}
public void setFromAccountKey(String fromAccountKey) {
this.fromAccountKey = fromAccountKey;
}
public String getToAccountKey() {
return toAccountKey;
}
public void setToAccountKey(String toAccountKey) {
this.toAccountKey = toAccountKey;
}
public String getPeriodId() {
return periodId;
}
public void setPeriodId(String periodId) {
this.periodId = periodId;
}
public String getLimit() {
return limit;
}
public void setLimit(String limit) {
this.limit = limit;
}
public String getOrder() {
return order;
}
public void setOrder(String order) {
this.order = order;
}
public String getTaxRelevant() {
return taxRelevant;
}
public void setTaxRelevant(String taxRelevant) {
this.taxRelevant = taxRelevant;
}
@Override
public String toString() {
return ReflectionToStringBuilder.toString(this);
}
}

View File

@@ -0,0 +1,102 @@
package de.financer.dto;
import de.financer.model.Account;
import java.time.LocalDate;
public class SearchTransactionsResponseDto {
private Long id;
private Account fromAccount;
private Account toAccount;
private LocalDate date;
private String description;
private long amount;
private boolean taxRelevant;
private boolean recurring;
public SearchTransactionsResponseDto() {
// No-arg constructor for Jackson
}
public SearchTransactionsResponseDto(Long id,
Account fromAccount,
Account toAccount,
LocalDate date,
String description,
long amount,
boolean taxRelevant,
boolean recurring) {
this.id = id;
this.fromAccount = fromAccount;
this.toAccount = toAccount;
this.date = date;
this.description = description;
this.amount = amount;
this.taxRelevant = taxRelevant;
this.recurring = recurring;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = 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 boolean isTaxRelevant() {
return taxRelevant;
}
public void setTaxRelevant(boolean taxRelevant) {
this.taxRelevant = taxRelevant;
}
public boolean isRecurring() {
return recurring;
}
public void setRecurring(boolean recurring) {
this.recurring = recurring;
}
}

View File

@@ -0,0 +1,14 @@
package de.financer.dto.comparator;
import de.financer.model.Transaction;
import java.util.Comparator;
public class TransactionsByDateByIdDescComparator implements Comparator<Transaction> {
@Override
public int compare(Transaction o1, Transaction o2) {
int dateOrder = o1.getDate().compareTo(o2.getDate()) * -1;
return dateOrder != 0 ? dateOrder : o1.getId().compareTo(o2.getId()) * -1;
}
}

View File

@@ -0,0 +1,4 @@
/**
* All implementations need to declare an (implicit) parameterless default constructor
*/
package de.financer.dto.comparator;

View File

@@ -17,6 +17,7 @@ public class Account {
private Long currentBalance;
@ManyToOne
private AccountGroup accountGroup;
// TODO need to be lazy and disabled via Jackson View
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "account_id")
private Set<AccountStatistic> accountStatistics;

View File

@@ -57,6 +57,11 @@
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>4.8-1</version>
</dependency>
<!-- Financer dependencies -->
<dependency>
<groupId>de.77zzcx7.financer</groupId>
@@ -93,6 +98,28 @@
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>4.8-1</version>
<executions>
<execution>
<id>antlr</id>
<goals>
<goal>antlr4</goal>
</goals>
<configuration>
<visitor>true</visitor>
<listener>false</listener>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<reporting>
<plugins>
<plugin>

View File

@@ -0,0 +1,103 @@
grammar FQL;
/*
* Parser rules
*/
query
: (orExpression | orderByExpression EOF)* ;
orExpression
: andExpression (OR andExpression)* ;
andExpression
: expression (AND expression)* ;
expression
: (regularExpression | betweenExpression | periodExpression)
| parenthesisExpression ;
parenthesisExpression
: L_PAREN expr=orExpression R_PAREN ;
orderByExpression
: ORDER_BY field=IDENTIFIER order=(DESC | ASC ) ;
// regular expressions - NOT regex
regularExpression
: (stringExpression | intExpression | booleanExpression | dateExpression) ;
stringExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=STRING_VALUE ;
intExpression
: field=IDENTIFIER operator=(INT_OPERATOR | STRING_OPERATOR) value=INT_VALUE ;
booleanExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=(TRUE | FALSE) ;
dateExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=DATE_VALUE ;
// BETWEEN expressions
betweenExpression
: (betweenStringExpression | betweenIntExpression ) ;
betweenStringExpression
: field=IDENTIFIER BETWEEN left=STRING_VALUE AND right=STRING_VALUE ;
betweenIntExpression
: field=IDENTIFIER BETWEEN left=INT_VALUE AND right=INT_VALUE ;
// period expressions
periodExpression
: periodConstExpression ;
periodConstExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=(CURRENT |
LAST |
CURRENT_YEAR |
LAST_YEAR |
GRAND_TOTAL) ;
/*
* Lexer rules
*/
fragment B : ('B' | 'b') ;
fragment E : ('E' | 'e') ;
fragment T : ('T' | 't') ;
fragment W : ('W' | 'w') ;
fragment N : ('N' | 'n') ;
fragment A : ('A' | 'a') ;
fragment D : ('D' | 'd') ;
fragment C : ('C' | 'c') ;
fragment U : ('U' | 'u') ;
fragment R : ('R' | 'r') ;
fragment L : ('L' | 'l') ;
fragment S : ('S' | 's') ;
fragment Y : ('Y' | 'y') ;
fragment G : ('G' | 'g') ;
fragment O : ('O' | 'o') ;
fragment F : ('F' | 'f') ;
fragment SPACE : ' ' ;
// Keywords
BETWEEN : B E T W E E N ;
AND : A N D ;
OR : O R ;
ORDER_BY : O R D E R SPACE B Y ;
DESC : D E S C ;
ASC : A S C ;
TRUE : T R U E ;
FALSE : F A L S E ;
// Constant values
CURRENT : C U R R E N T ;
LAST : L A S T ;
YEAR : Y E A R ;
GRAND_TOTAL : G R A N D '_' T O T A L ;
CURRENT_YEAR : CURRENT '_' YEAR ;
LAST_YEAR : LAST '_' YEAR ;
STRING_OPERATOR : '=' ;
INT_OPERATOR : '>' | '<' | '>=' | '<=' | '!=' ;
L_PAREN : '(' ;
R_PAREN : ')' ;
IDENTIFIER : [a-zA-Z]+ ;
INT_VALUE : [0-9]+ ;
STRING_VALUE : '\'' [a-zA-Z0-9- ]+ '\'' ;
DATE_VALUE : [0-9-]+ ;
NEWLINE : ('\r'? '\n' | '\r')+ ;
WHITESPACE : ' ' -> skip;

View File

@@ -11,10 +11,10 @@ import java.util.List;
public class V22_0_1__calculateAccountStatistics extends BaseJavaMigration {
private static class TransactionPeriodAccountsContainer {
public Long transactionAmount;
public Long expensePeriodId;
public Long fromAccountId;
public Long toAccountId;
public final Long transactionAmount;
public final Long expensePeriodId;
public final Long fromAccountId;
public final Long toAccountId;
public TransactionPeriodAccountsContainer(Long transactionAmount, Long expensePeriodId, Long fromAccountId, Long toAccountId) {
this.transactionAmount = transactionAmount;

View File

@@ -12,10 +12,10 @@ import java.util.List;
public class V26_0_0__createGrandTotalPeriod extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;

View File

@@ -14,10 +14,10 @@ public class V26_0_1__createExpenseYearPeriod_2019 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;

View File

@@ -6,7 +6,6 @@ import org.flywaydb.core.api.migration.Context;
import java.sql.*;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalField;
import java.util.ArrayList;
import java.util.List;
@@ -14,10 +13,10 @@ public class V26_0_2__createExpenseYearPeriod_2020 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;

View File

@@ -1,45 +0,0 @@
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.BAD_REQUEST),
FROM_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
TO_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
FROM_AND_TO_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST),
MISSING_DATE(HttpStatus.BAD_REQUEST),
AMOUNT_ZERO(HttpStatus.BAD_REQUEST),
MISSING_AMOUNT(HttpStatus.BAD_REQUEST),
INVALID_BOOKING_ACCOUNTS(HttpStatus.BAD_REQUEST),
MISSING_HOLIDAY_WEEKEND_TYPE(HttpStatus.BAD_REQUEST),
INVALID_HOLIDAY_WEEKEND_TYPE(HttpStatus.BAD_REQUEST),
MISSING_INTERVAL_TYPE(HttpStatus.BAD_REQUEST),
INVALID_INTERVAL_TYPE(HttpStatus.BAD_REQUEST),
MISSING_FIRST_OCCURRENCE(HttpStatus.BAD_REQUEST),
INVALID_FIRST_OCCURRENCE_FORMAT(HttpStatus.BAD_REQUEST),
INVALID_LAST_OCCURRENCE_FORMAT(HttpStatus.BAD_REQUEST),
MISSING_RECURRING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
INVALID_RECURRING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
RECURRING_TRANSACTION_NOT_FOUND(HttpStatus.BAD_REQUEST),
MISSING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
INVALID_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
TRANSACTION_NOT_FOUND(HttpStatus.BAD_REQUEST),
ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
DUPLICATE_ACCOUNT_KEY(HttpStatus.BAD_REQUEST),
DUPLICATE_ACCOUNT_GROUP_NAME(HttpStatus.BAD_REQUEST),
ACCOUNT_GROUP_NOT_FOUND(HttpStatus.BAD_REQUEST);
private HttpStatus httpStatus;
ResponseReason(HttpStatus httpStatus) {
this.httpStatus = httpStatus;
}
public ResponseEntity toResponseEntity() {
return new ResponseEntity<>(this.name(), this.httpStatus);
}
}

View File

@@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import javax.management.ReflectionException;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collection;
@@ -68,10 +67,10 @@ public class FinancerConfig {
/**
* @return the date format used in e.g. the {@link de.financer.service.TransactionService#createTransaction(String,
* String, Long, String, String) TransactionService#createTransaction} or {@link
* String, Long, String, String, Boolean)} 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
* String, String, String, Boolean, Boolean)} RecurringTransactionService#createRecurringTransaction} methods. Used
* to parse the client-supplied date string to proper {@link java.time.LocalDate LocalDate} objects
*/
public String getDateFormat() {
return dateFormat;

View File

@@ -11,7 +11,7 @@ public class ControllerUtil {
* @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) {
public static String urlDecode(String toDecode) {
try {
return URLDecoder.decode(toDecode, StandardCharsets.UTF_8.name());
}

View File

@@ -10,8 +10,6 @@ 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

View File

@@ -2,125 +2,160 @@ package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.model.Transaction;
import de.financer.dto.SaveTransactionRequestDto;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.service.TransactionService;
import de.financer.service.parameter.SearchTransactionsParameter;
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 org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("transactions")
public class TransactionController {
private static final Logger LOGGER = LoggerFactory.getLogger(TransactionController.class);
@Autowired
private TransactionService transactionService;
@RequestMapping("getAll")
public Iterable<Transaction> getAll() {
return this.transactionService.getAll();
}
@RequestMapping("getAllForAccount")
public Iterable<Transaction> getAllForAccount(String accountKey) {
final String decoded = ControllerUtil.urlDecode(accountKey);
@GetMapping("/transactionsByFql")
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
final String fqlDecoded = ControllerUtil.urlDecode(fql);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getAllForAccount got parameter: %s", decoded));
LOGGER.debug(String.format("GET /transactionsByFql/ got parameter: %s", fqlDecoded));
}
return this.transactionService.getAllForAccount(decoded);
}
@RequestMapping(value = "createTransaction")
public ResponseEntity createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description, Boolean taxRelevant
) {
final String decodedFrom = ControllerUtil.urlDecode(fromAccountKey);
final String decodedTo = ControllerUtil.urlDecode(toAccountKey);
final String decodedDesc = ControllerUtil.urlDecode(description);
final Iterable<SearchTransactionsResponseDto> transactionSearchResponse =
this.transactionService.searchTransactions(fqlDecoded);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/createTransaction got parameters: %s, %s, %s, %s, %s, %s",
decodedFrom, decodedTo, amount, date, decodedDesc, taxRelevant));
LOGGER.debug(String.format("GET /transactionsByFql returns with %s", transactionSearchResponse));
}
return transactionSearchResponse;
}
@GetMapping("/transactions")
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fromAccountKey,
String toAccountKey,
String periodId,
String limit,
String order,
String taxRelevant,
String accountsAnd) {
final String fromDecoded = ControllerUtil.urlDecode(fromAccountKey);
final String toDecoded = ControllerUtil.urlDecode(toAccountKey);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("GET /transactions/ got parameter: %s, %s, %s, %s, %s, %s - with order %s",
fromDecoded, toDecoded, periodId, limit, taxRelevant, accountsAnd, order));
}
// Wrap the url parameters into a proper POJO so the service parameter list wont get polluted
// There is no validation taking place here, only the wrapping. Validation happens in the service.
final SearchTransactionsParameter parameter = new SearchTransactionsParameter();
parameter.setFromAccountKey(fromDecoded);
parameter.setToAccountKey(toDecoded);
parameter.setLimit(limit);
parameter.setPeriodId(periodId);
parameter.setOrder(order);
parameter.setTaxRelevant(taxRelevant);
parameter.setAccountsAnd(accountsAnd);
final Iterable<SearchTransactionsResponseDto> transactionSearchResponse =
this.transactionService.searchTransactions(parameter);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("GET /transactions returns with %s", transactionSearchResponse));
}
return transactionSearchResponse;
}
@PostMapping(value = "/transactions")
public ResponseEntity createTransaction(@RequestBody SaveTransactionRequestDto requestDto) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("POST /transactions got parameters: %s", requestDto));
}
final ResponseReason responseReason = this.transactionService
.createTransaction(decodedFrom, decodedTo, amount, date, decodedDesc, taxRelevant);
.createTransaction(requestDto.getFromAccountKey(),
requestDto.getToAccountKey(),
//TODO Conversion should be moved to
//TODO service and cause a proper response
//TODO if the value is not numeric
Long.valueOf(requestDto.getAmount()),
requestDto.getDate(),
requestDto.getDescription(),
requestDto.getTaxRelevant());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/createTransaction returns with %s", responseReason.name()));
LOGGER.debug(String.format("POST /transactions returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("deleteTransaction")
public ResponseEntity deleteTransaction(String transactionId) {
@DeleteMapping("/transactions/{transactionId}")
public ResponseEntity deleteTransaction(@PathVariable String transactionId) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction got parameters: %s",
transactionId));
LOGGER.debug(String.format("DELETE /transactions/{transactionId} got parameters: %s", transactionId));
}
final ResponseReason responseReason = this.transactionService
.deleteTransaction(transactionId);
final ResponseReason responseReason = this.transactionService.deleteTransaction(transactionId);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction returns with %s", responseReason.name()));
LOGGER.debug(String.format("DELETE /transactions/{transactionId} returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("getExpensesCurrentPeriod")
@GetMapping("/transactions/getExpensesCurrentPeriod")
public Long getExpensesCurrentPeriod() {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesCurrentPeriod called"));
LOGGER.debug(String.format("GET /transactions/getExpensesCurrentPeriod called"));
}
final Long response = this.transactionService.getExpensesCurrentPeriod();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesCurrentPeriod returns with %s", response));
LOGGER.debug(String.format("GET /transactions/getExpensesCurrentPeriod returns with %s", response));
}
return response;
}
@RequestMapping("getExpensePeriodTotals")
@GetMapping("/transactions/getExpensePeriodTotals")
public Iterable<ExpensePeriodTotal> getExpensePeriodTotals(Integer year) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensePeriodTotals got parameters: %s", year));
LOGGER.debug(String.format("GET /transactions/getExpensePeriodTotals got parameters: %s", year));
}
final Iterable<ExpensePeriodTotal> expensePeriodTotals = this.transactionService
.getExpensePeriodTotals(year);
final Iterable<ExpensePeriodTotal> expensePeriodTotals = this.transactionService.getExpensePeriodTotals(year);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensePeriodTotals returns with %s", expensePeriodTotals));
LOGGER.debug(String.format("GET /transactions/getExpensePeriodTotals returns with %s", expensePeriodTotals));
}
return expensePeriodTotals;
}
@RequestMapping("getExpensesAllPeriods")
@GetMapping("/transactions/getExpensesAllPeriods")
public List<Long> getExpensesAllPeriods() {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesAllPeriods called"));
LOGGER.debug(String.format("GET /transactions/getExpensesAllPeriods called"));
}
final List<Long> response = this.transactionService.getExpensesAllPeriods();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesAllPeriods returns with %s", response));
LOGGER.debug(String.format("GET /transactions/getExpensesAllPeriods returns with %s", response));
}
return response;

View File

@@ -7,7 +7,6 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Transactional(propagation = Propagation.REQUIRED)

View File

@@ -4,7 +4,6 @@ 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;

View File

@@ -7,11 +7,10 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Transactional(propagation = Propagation.REQUIRED)
public interface TransactionRepository extends CrudRepository<Transaction, Long> {
public interface TransactionRepository extends CrudRepository<Transaction, Long>, TransactionRepositoryCustom {
@Query("SELECT t FROM Transaction t WHERE t.toAccount = :toAccount OR t.fromAccount = :fromAccount")
Iterable<Transaction> findTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);

View File

@@ -0,0 +1,18 @@
package de.financer.dba;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.model.Account;
import de.financer.model.Period;
public interface TransactionRepositoryCustom {
Iterable<SearchTransactionsResponseDto> searchTransactions(Period period,
Account fromAccount,
Account toAccount,
Integer limit,
Order order,
Boolean taxRelevant,
boolean accountsAnd);
Iterable<SearchTransactionsResponseDto> searchTransactions(String fql);
}

View File

@@ -0,0 +1,117 @@
package de.financer.dba.impl;
import de.financer.dba.TransactionRepositoryCustom;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.FQLLexer;
import de.financer.fql.FQLParser;
import de.financer.fql.FQLVisitorImpl;
import de.financer.model.*;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.*;
import java.util.ArrayList;
import java.util.List;
@Repository
public class TransactionRepositoryCustomImpl implements TransactionRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public Iterable<SearchTransactionsResponseDto> searchTransactions(Period period,
Account fromAccount,
Account toAccount,
Integer limit,
Order order,
Boolean taxRelevant,
boolean accountsAnd) {
final CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder();
final CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery = criteriaBuilder
.createQuery(SearchTransactionsResponseDto.class);
final Root<Transaction> fromTransaction = criteriaQuery.from(Transaction.class);
final List<Predicate> predicates = new ArrayList<>();
Predicate fromAccountPredicate = null;
Predicate toAccountPredicate = null;
if (taxRelevant != null) {
predicates.add(criteriaBuilder.equal(fromTransaction.get(Transaction_.taxRelevant), taxRelevant));
}
if (fromAccount != null) {
fromAccountPredicate = criteriaBuilder.equal(fromTransaction.get(Transaction_.fromAccount), fromAccount);
}
if (toAccount != null) {
toAccountPredicate = criteriaBuilder.equal(fromTransaction.get(Transaction_.toAccount), toAccount);
}
if (period != null) {
final SetJoin<Transaction, Period> periodJoin = fromTransaction.join(Transaction_.periods);
predicates.add(criteriaBuilder.equal(periodJoin.get(Period_.id), period.getId()));
}
if (fromAccountPredicate != null && toAccountPredicate != null) {
if (accountsAnd) {
predicates.add(criteriaBuilder.and(fromAccountPredicate, toAccountPredicate));
}
else {
predicates.add(criteriaBuilder.or(fromAccountPredicate, toAccountPredicate));
}
}
else if (fromAccountPredicate != null) {
predicates.add(fromAccountPredicate);
}
else if (toAccountPredicate != null) {
predicates.add(toAccountPredicate);
}
// else: both null, nothing to do
criteriaQuery.where(predicates.toArray(new Predicate[]{}));
switch(order) {
case TRANSACTIONS_BY_DATE_DESC:
// either leave case as last before default if new cases arrive
// or copy the expression
default:
criteriaQuery.orderBy(criteriaBuilder.desc(fromTransaction.get(Transaction_.date)));
}
criteriaQuery.select(criteriaBuilder.construct(SearchTransactionsResponseDto.class,
fromTransaction.get(Transaction_.id),
fromTransaction.get(Transaction_.fromAccount),
fromTransaction.get(Transaction_.toAccount),
fromTransaction.get(Transaction_.date),
fromTransaction.get(Transaction_.description),
fromTransaction.get(Transaction_.amount),
fromTransaction.get(Transaction_.taxRelevant),
criteriaBuilder.isNotNull(fromTransaction.get(Transaction_.recurringTransaction))));
final TypedQuery<SearchTransactionsResponseDto> query = this.entityManager.createQuery(criteriaQuery);
if (limit != null) {
query.setMaxResults(limit);
}
return query.getResultList();
}
@Override
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
final FQLLexer lexer = new FQLLexer(CharStreams.fromString(fql));
final FQLParser parser = new FQLParser(new CommonTokenStream(lexer));
final CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder();
final FQLVisitorImpl visitor = new FQLVisitorImpl(criteriaBuilder);
visitor.visitQuery(parser.query());
return this.entityManager.createQuery(visitor.getCriteriaQuery()).getResultList();
}
}

View File

@@ -0,0 +1,230 @@
package de.financer.fql;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.field_handler.BetweenIntHandler;
import de.financer.fql.field_handler.BetweenStringHandler;
import de.financer.fql.field_handler.PeriodConstHandler;
import de.financer.fql.join_handler.JoinKey;
import de.financer.fql.field_handler.IntHandler;
import de.financer.model.*;
import org.antlr.v4.runtime.tree.ParseTree;
import org.apache.commons.lang3.StringUtils;
import javax.persistence.criteria.*;
import java.util.*;
public class FQLVisitorImpl extends FQLBaseVisitor<Predicate> {
private final CriteriaBuilder criteriaBuilder;
private final CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery;
private final Root<Transaction> criteriaRoot;
private final Map<JoinKey, From<?, ?>> froms;
public FQLVisitorImpl(CriteriaBuilder criteriaBuilder) {
this.criteriaBuilder = criteriaBuilder;
this.criteriaQuery = criteriaBuilder.createQuery(SearchTransactionsResponseDto.class);
this.criteriaRoot = criteriaQuery.from(Transaction.class);
this.froms = new HashMap<>();
this.froms.put(JoinKey.of(Transaction.class), this.criteriaRoot);
}
public CriteriaQuery<SearchTransactionsResponseDto> getCriteriaQuery() {
return this.criteriaQuery;
}
@Override
public Predicate visitQuery(FQLParser.QueryContext ctx) {
buildSelect();
final Predicate predicateTree = ctx.getChild(0).accept(this);
// ORDER BY
if (ctx.getChildCount() > 1) {
ctx.getChild(1).accept(this);
}
this.criteriaQuery.where(predicateTree);
return predicateTree;
}
private void buildSelect() {
criteriaQuery.select(criteriaBuilder.construct(SearchTransactionsResponseDto.class,
this.criteriaRoot.get(Transaction_.id),
this.criteriaRoot.get(Transaction_.fromAccount),
this.criteriaRoot.get(Transaction_.toAccount),
this.criteriaRoot.get(Transaction_.date),
this.criteriaRoot.get(Transaction_.description),
this.criteriaRoot.get(Transaction_.amount),
this.criteriaRoot.get(Transaction_.taxRelevant),
criteriaBuilder.isNotNull(this.criteriaRoot.get(Transaction_.recurringTransaction))));
}
@Override
public Predicate visitOrExpression(FQLParser.OrExpressionContext ctx) {
if (ctx.getChildCount() == 1) {
return ctx.getChild(0).accept(this);
} else {
final List<Predicate> predicates = new ArrayList<>();
for (int i = 0; i < ctx.getChildCount(); i++) {
ParseTree c = ctx.getChild(i);
Predicate p = c.accept(this);
if (p != null) {
predicates.add(p);
}
}
return this.criteriaBuilder.or(predicates.toArray(new Predicate[]{}));
}
}
@Override
public Predicate visitAndExpression(FQLParser.AndExpressionContext ctx) {
if (ctx.getChildCount() == 1) {
return ctx.getChild(0).accept(this);
} else {
final List<Predicate> predicates = new ArrayList<>();
for (int i = 0; i < ctx.getChildCount(); i++) {
ParseTree c = ctx.getChild(i);
Predicate p = c.accept(this);
if (p != null) {
predicates.add(p);
}
}
return this.criteriaBuilder.and(predicates.toArray(new Predicate[]{}));
}
}
@Override
public Predicate visitExpression(FQLParser.ExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitParenthesisExpression(FQLParser.ParenthesisExpressionContext ctx) {
return ctx.expr.accept(this);
}
@Override
public Predicate visitRegularExpression(FQLParser.RegularExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitBooleanExpression(FQLParser.BooleanExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final Boolean value = Boolean.parseBoolean(ctx.value.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, value);
}
@Override
public Predicate visitStringExpression(FQLParser.StringExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler()
.apply(fieldMapping, this.froms, this.criteriaBuilder, ctx.value.getText());
}
@Override
public Predicate visitIntExpression(FQLParser.IntExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final IntHandler.IntHandlerParameterContainer con = IntHandler.IntHandlerParameterContainer
.of(ctx.operator.getText(), ctx.value.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitDateExpression(FQLParser.DateExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler()
.apply(fieldMapping, this.froms, this.criteriaBuilder, ctx.value.getText());
}
@Override
public Predicate visitBetweenExpression(FQLParser.BetweenExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitBetweenStringExpression(FQLParser.BetweenStringExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final BetweenStringHandler.BetweenStringHandlerParameterContainer con = BetweenStringHandler.BetweenStringHandlerParameterContainer
.of(ctx.left.getText(), ctx.right.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
// Overwrite the actual field handler of the field
return new BetweenStringHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitBetweenIntExpression(FQLParser.BetweenIntExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final BetweenIntHandler.BetweenIntHandlerParameterContainer con = BetweenIntHandler.BetweenIntHandlerParameterContainer
.of(ctx.left.getText(), ctx.right.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
// Overwrite the actual field handler of the field
return new BetweenIntHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitPeriodExpression(FQLParser.PeriodExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitPeriodConstExpression(FQLParser.PeriodConstExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final PeriodConstHandler.PeriodConstHandlerParameterContainer con = PeriodConstHandler.PeriodConstHandlerParameterContainer
.of(ctx.value.getText(), this.criteriaQuery);
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitOrderByExpression(FQLParser.OrderByExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final String orderValue = StringUtils.upperCase(ctx.order.getText());
Order order;
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
switch (orderValue) {
case "DESC":
order = this.criteriaBuilder
.desc(this.froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
break;
case "ASC":
order = this.criteriaBuilder
.asc(this.froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
break;
default:
throw new IllegalArgumentException(String.format("Unknown order value: %s", ctx.order.getText()));
}
this.criteriaQuery.orderBy(order);
return null;
}
}

View File

@@ -0,0 +1,119 @@
package de.financer.fql;
import de.financer.fql.field_handler.*;
import de.financer.fql.join_handler.*;
import de.financer.model.*;
import java.util.Arrays;
public enum FieldMapping {
AMOUNT("amount", Transaction_.AMOUNT, Transaction.class, NoopJoinHandler.class, JoinKey
.of(Transaction.class), null, IntHandler.class),
PERIOD("period", Period_.ID, Period.class, PeriodJoinHandler.class, JoinKey.of(Period.class), null, PeriodConstHandler.class),
FROM_ACCOUNT("fromAccount", Account_.KEY, Account.class, FromAccountJoinHandler.class, JoinKey
.of(Account.class, "FROM"), null, StringHandler.class),
TO_ACCOUNT("toAccount", Account_.KEY, Account.class, ToAccountJoinHandler.class, JoinKey
.of(Account.class, "TO"), null, StringHandler.class),
FROM_ACCOUNT_GROUP("fromAccountGroup", AccountGroup_.NAME, AccountGroup.class,
AccountGroupJoinHandler.class, JoinKey.of(AccountGroup.class, "FROM"), FROM_ACCOUNT, StringHandler.class),
TO_ACCOUNT_GROUP("toAccountGroup", AccountGroup_.NAME, AccountGroup.class,
AccountGroupJoinHandler.class, JoinKey.of(AccountGroup.class, "TO"), TO_ACCOUNT, StringHandler.class),
DATE("date", Transaction_.DATE, Transaction.class, NoopJoinHandler.class, JoinKey.of(Transaction.class), null, DateHandler.class),
RECURRING("recurring", Transaction_.RECURRING_TRANSACTION, Transaction.class, NoopJoinHandler.class,
JoinKey.of(Transaction.class), null, NotNullSyntheticHandler.class),
TAX_RELEVANT("taxRelevant", Transaction_.TAX_RELEVANT, Transaction.class, NoopJoinHandler.class,
JoinKey.of(Transaction.class), null, BooleanHandler.class);
/**
* The name of the field as used in FQL
*/
private final String fieldName;
/**
* The name of the ORM field
*/
private final String attributeName;
/**
* The entity this field belongs to
*/
private final Class<?> entityClass;
/**
* The join handler responsible for constructing the join if the field is not part of the root entity
*/
private final Class<? extends JoinHandler> joinHandlerClass;
/**
* A synthetic key to uniquely identify the join required for this field
*/
private final JoinKey joinKey;
/**
* The field mapping this field depends on if the entity is not directly reachable from the root entity
*/
private final FieldMapping dependingJoin;
/**
* The handler for this FQL field handling the translation to a JPA criteria
*/
private final Class<? extends FieldHandler> fieldHandlerClass;
FieldMapping(String fieldName, String attributeName, Class<?> entityClass,
Class<? extends JoinHandler> joinHandlerClass, JoinKey joinKey,
FieldMapping dependingJoin, Class<? extends FieldHandler> fieldHandlerClass
) {
this.fieldName = fieldName;
this.attributeName = attributeName;
this.entityClass = entityClass;
this.joinHandlerClass = joinHandlerClass;
this.joinKey = joinKey;
this.dependingJoin = dependingJoin;
this.fieldHandlerClass = fieldHandlerClass;
}
public static FieldMapping findByFieldName(String fqlFieldName) {
return Arrays.stream(values()).filter(fm -> fm.fieldName.equalsIgnoreCase(fqlFieldName)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown field: " + fqlFieldName));
}
public String getAttributeName() {
return attributeName;
}
public Class<?> getEntityClass() {
return entityClass;
}
public JoinHandler getJoinHandler() {
try {
return this.joinHandlerClass.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("No public, parameterless constructor found!");
}
}
public FieldHandler getFieldHandler() {
try {
return this.fieldHandlerClass.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("No public, parameterless constructor found!");
}
}
public JoinKey getJoinKey() {
return this.joinKey;
}
public FieldMapping getDependingJoin() {
return this.dependingJoin;
}
}

View File

@@ -0,0 +1,32 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BetweenIntHandler implements FieldHandler<BetweenIntHandler.BetweenIntHandlerParameterContainer> {
public static class BetweenIntHandlerParameterContainer {
private Long left;
private Long right;
public static BetweenIntHandlerParameterContainer of(String left, String right) {
final BetweenIntHandlerParameterContainer con = new BetweenIntHandlerParameterContainer();
con.left = Long.valueOf(left);
con.right = Long.valueOf(right);
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, BetweenIntHandlerParameterContainer con) {
return criteriaBuilder
.between(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), con.left, con.right);
}
}

View File

@@ -0,0 +1,38 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BetweenStringHandler implements FieldHandler<BetweenStringHandler.BetweenStringHandlerParameterContainer> {
public static class BetweenStringHandlerParameterContainer {
private String left;
private String right;
public static BetweenStringHandlerParameterContainer of(String left, String right) {
final BetweenStringHandlerParameterContainer con = new BetweenStringHandlerParameterContainer();
con.left = left;
con.right = right;
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, BetweenStringHandlerParameterContainer con) {
return criteriaBuilder
.between(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()),
removeQuotes(con.left),
removeQuotes(con.right));
}
private String removeQuotes(String value) {
return value.replaceAll("'", "");
}
}

View File

@@ -0,0 +1,16 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BooleanHandler implements FieldHandler<Boolean> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, Boolean value) {
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), value);
}
}

View File

@@ -0,0 +1,21 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.time.LocalDate;
import java.util.Map;
public class DateHandler implements FieldHandler<String> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, String value) {
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), parseDate(value));
}
private LocalDate parseDate(String value) {
return LocalDate.parse(value);
}
}

View File

@@ -0,0 +1,13 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public interface FieldHandler<T> {
Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, T value);
}

View File

@@ -0,0 +1,55 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Path;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class IntHandler implements FieldHandler<IntHandler.IntHandlerParameterContainer> {
public static class IntHandlerParameterContainer {
private String operator;
private Long value;
public static IntHandlerParameterContainer of(String operator, String value) {
IntHandlerParameterContainer con = new IntHandlerParameterContainer();
con.operator = operator;
con.value = Long.valueOf(value);
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, IntHandlerParameterContainer con) {
final Path<Long> path = froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName());
Predicate retVal = null;
switch (con.operator) {
case "=":
retVal = criteriaBuilder.equal(path, con.value);
break;
case ">":
retVal = criteriaBuilder.gt(path, con.value);
break;
case "<":
retVal = criteriaBuilder.lt(path, con.value);
break;
case ">=":
retVal = criteriaBuilder.greaterThanOrEqualTo(path, con.value);
break;
case "<=":
retVal = criteriaBuilder.lessThanOrEqualTo(path, con.value);
break;
case "!=":
retVal = criteriaBuilder.notEqual(path, con.value);
break;
}
return retVal;
}
}

View File

@@ -0,0 +1,20 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class NotNullSyntheticHandler implements FieldHandler<Boolean> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, Boolean value) {
if (value) {
return criteriaBuilder.isNotNull(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
} else {
return criteriaBuilder.isNull(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
}
}
}

View File

@@ -0,0 +1,73 @@
package de.financer.fql.field_handler;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import de.financer.model.Period;
import de.financer.model.PeriodType;
import de.financer.model.Period_;
import org.apache.commons.lang3.StringUtils;
import javax.persistence.criteria.*;
import java.util.Map;
public class PeriodConstHandler implements FieldHandler<PeriodConstHandler.PeriodConstHandlerParameterContainer> {
public static class PeriodConstHandlerParameterContainer {
private String value;
private CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery;
public static PeriodConstHandlerParameterContainer of(String value, CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery) {
final PeriodConstHandlerParameterContainer con = new PeriodConstHandlerParameterContainer();
con.value = value;
con.criteriaQuery = criteriaQuery;
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, PeriodConstHandlerParameterContainer con) {
final Subquery<Long> subquery = con.criteriaQuery.subquery(Long.class);
final Root<Period> subFrom = subquery.from(Period.class);
final String upperValue = StringUtils.upperCase(con.value);
subquery.select(subFrom.get(fieldMapping.getAttributeName()));
// Generate subselect for each period value
switch (upperValue) {
case "CURRENT":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE), criteriaBuilder
.isNull(subFrom.get(Period_.END))));
break;
case "LAST":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE), criteriaBuilder
.isNotNull(subFrom.get(Period_.END))));
// subquery.orderBy -> orderBy not supported by JPA criteria
// So we need to workaround this by misusing the autoincremented ID
subquery.select(criteriaBuilder.max(subFrom.get(fieldMapping.getAttributeName())));
break;
case "CURRENT_YEAR":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE_YEAR), criteriaBuilder
.isNull(subFrom.get(Period_.END))));
break;
case "LAST_YEAR":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE_YEAR), criteriaBuilder
.isNotNull(subFrom.get(Period_.END))));
subquery.select(criteriaBuilder.max(subFrom.get(fieldMapping.getAttributeName())));
break;
case "GRAND_TOTAL":
subquery.where(criteriaBuilder.equal(subFrom.get(Period_.TYPE), PeriodType.GRAND_TOTAL));
break;
}
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey())
.get(fieldMapping.getAttributeName()), subquery);
}
}

View File

@@ -0,0 +1,21 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class StringHandler implements FieldHandler<String> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, String value) {
return criteriaBuilder
.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), removeQuotes(value));
}
private String removeQuotes(String value) {
return value.replaceAll("'", "");
}
}

View File

@@ -0,0 +1,32 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.AccountGroup;
import de.financer.model.Account_;
import de.financer.model.Transaction;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import java.util.Map;
public class AccountGroupJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
final FieldMapping dependingJoin = fieldMapping.getDependingJoin();
if (dependingJoin != null) {
dependingJoin.getJoinHandler().apply(joinKeyFromMap, dependingJoin);
}
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Account, AccountGroup> accountGroupJoin = ((Join<Transaction, Account>) joinKeyFromMap
.get(dependingJoin.getJoinKey()))
.join(Account_.accountGroup);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountGroupJoin);
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.Root;
import java.util.Map;
public class FromAccountJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Transaction, Account> accountJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.fromAccount);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountJoin);
}
return null;
}
}

View File

@@ -0,0 +1,10 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import javax.persistence.criteria.From;
import java.util.Map;
import java.util.function.BiFunction;
public interface JoinHandler extends BiFunction<Map<JoinKey, From<?, ?>>, FieldMapping, Void> {
}

View File

@@ -0,0 +1,36 @@
package de.financer.fql.join_handler;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
public class JoinKey {
private Class<?> clazz;
private String key;
public static JoinKey of(Class<?> clazz, String key) {
JoinKey jk = new JoinKey();
jk.clazz = clazz;
jk.key = key;
return jk;
}
public static JoinKey of(Class<?> clazz) {
JoinKey jk = new JoinKey();
jk.clazz = clazz;
return jk;
}
@Override
public boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj, false);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this, false);
}
}

View File

@@ -0,0 +1,13 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import javax.persistence.criteria.From;
import java.util.Map;
public class NoopJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
return null;
}
}

View File

@@ -0,0 +1,28 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Period;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.SetJoin;
import java.util.Map;
public class PeriodJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final SetJoin<Transaction, Period> periodJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.periods);
joinKeyFromMap.put(fieldMapping.getJoinKey(), periodJoin);
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.Root;
import java.util.Map;
public class ToAccountJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Transaction, Account> accountJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.toAccount);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountJoin);
}
return null;
}
}

View File

@@ -43,6 +43,7 @@ public class AccountService {
* @return the account or <code>null</code> if no account with the given key can be found
*/
public Account getAccountByKey(String key) {
// TODO change return type of repository to Optional<Account>
return this.accountRepository.findByKey(key);
}
@@ -102,7 +103,7 @@ public class AccountService {
// 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));
account.setCurrentBalance(0L);
try {
this.accountRepository.save(account);

View File

@@ -12,10 +12,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.util.Optional;
@Service
public class PeriodService {
@@ -132,4 +131,8 @@ public class PeriodService {
return period;
}
public Optional<Period> getPeriodById(Long id) {
return this.periodRepository.findById(id);
}
}

View File

@@ -4,7 +4,12 @@ import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.dba.TransactionRepository;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.model.*;
import de.financer.service.exception.FinancerServiceException;
import de.financer.service.parameter.SearchTransactionsParameter;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.slf4j.Logger;
@@ -48,25 +53,6 @@ public class TransactionService {
return this.transactionRepository.findAll();
}
/**
* @param accountKey the key of the account to get the transactions for
*
* @return all transactions for the given account, for all time. Returns an empty list if the given key does not
* match any account.
*/
public Iterable<Transaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(accountKey);
if (account == null) {
LOGGER.warn(String.format("Account with key %s not found!", accountKey));
return Collections.emptyList();
}
// As we want all transactions of the given account use it as from and to account
return this.transactionRepository.findTransactionsByFromAccountOrToAccount(account, account);
}
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description, Boolean taxRelevant
@@ -114,7 +100,7 @@ public class TransactionService {
this.accountService.saveAccount(fromAccount);
this.accountService.saveAccount(toAccount);
response = ResponseReason.OK;
response = ResponseReason.CREATED;
} catch (Exception e) {
LOGGER.error("Could not create transaction!", e);
@@ -261,7 +247,7 @@ public class TransactionService {
final Long expensesCurrentPeriod = this.transactionRepository
.getExpensesForPeriod(expensePeriod, AccountType.EXPENSE, AccountType.LIABILITY);
return Optional.ofNullable(expensesCurrentPeriod).orElse(Long.valueOf(0l));
return Optional.ofNullable(expensesCurrentPeriod).orElse(0L);
}
/**
@@ -290,4 +276,93 @@ public class TransactionService {
return this.transactionRepository
.getAccountExpenseTotals(periods, AccountType.INCOME, AccountType.START, AccountType.EXPENSE, AccountType.LIABILITY);
}
/**
* This method searches for transactions as specified with the given search parameters. If no parameters at all are
* given the search returns the top 500 transactions for all accounts ordered by the transaction date descending.
*
* @param parameter the encapsulated search parameters
* @return the list of transactions matching the given parameters or the top 500 transactions. Never <code>null</code>
*/
public Iterable<SearchTransactionsResponseDto> searchTransactions(SearchTransactionsParameter parameter) {
// Check that at least one bounding parameter is set so the resulting list won't be too large
if (StringUtils.isEmpty(parameter.getPeriodId()) && StringUtils.isEmpty(parameter.getLimit())) {
// 500 is pretty conservative, could probably be increased if required to at least 1000
parameter.setLimit("500");
}
final Order order;
Period period = null;
Integer limit = null;
Account fromAccount = null;
Account toAccount = null;
Boolean taxRelevant = null;
Boolean accountsAnd = Boolean.FALSE; // account values are OR conjunct by default
if (StringUtils.isNotEmpty(parameter.getPeriodId())) {
if (!NumberUtils.isCreatable(parameter.getPeriodId())) {
throw new FinancerServiceException(ResponseReason.PERIOD_ID_NOT_NUMERIC);
}
final Optional<Period> optionalPeriod = this.periodService.getPeriodById(Long.valueOf(parameter.getPeriodId()));
period = optionalPeriod.orElseThrow(() -> new FinancerServiceException(ResponseReason.PERIOD_NOT_FOUND));
}
if (StringUtils.isNotEmpty(parameter.getLimit())) {
if (!NumberUtils.isCreatable(parameter.getLimit())) {
throw new FinancerServiceException(ResponseReason.LIMIT_NOT_NUMERIC);
}
limit = Integer.valueOf(parameter.getLimit());
}
order = Order.getByName(parameter.getOrder()).orElse(Order.TRANSACTIONS_BY_DATE_DESC);
if (StringUtils.isNotEmpty(parameter.getFromAccountKey())) {
fromAccount = this.accountService.getAccountByKey(parameter.getFromAccountKey());
if (fromAccount == null) {
throw new FinancerServiceException(ResponseReason.FROM_ACCOUNT_NOT_FOUND);
}
}
if (StringUtils.isNotEmpty(parameter.getToAccountKey())) {
toAccount = this.accountService.getAccountByKey(parameter.getToAccountKey());
if (toAccount == null) {
throw new FinancerServiceException(ResponseReason.TO_ACCOUNT_NOT_FOUND);
}
}
if (StringUtils.isNotEmpty(parameter.getTaxRelevant())) {
taxRelevant = BooleanUtils.toBooleanObject(parameter.getTaxRelevant());
if (taxRelevant == null) {
throw new FinancerServiceException(ResponseReason.INVALID_TAX_RELEVANT_VALUE);
}
}
if (StringUtils.isNotEmpty(parameter.getAccountsAnd())) {
accountsAnd = BooleanUtils.toBooleanObject(parameter.getAccountsAnd());
if (accountsAnd == null) {
throw new FinancerServiceException(ResponseReason.INVALID_ACCOUNTS_AND_VALUE);
}
}
return this.transactionRepository.searchTransactions(period, fromAccount, toAccount, limit, order, taxRelevant,
accountsAnd);
}
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
try {
return this.transactionRepository.searchTransactions(fql);
}
catch(IllegalArgumentException iae) {
LOGGER.error("Error while parsing FQL", iae);
throw new FinancerServiceException(ResponseReason.FQL_MALFORMED);
}
}
}

View File

@@ -0,0 +1,15 @@
package de.financer.service.exception;
import de.financer.ResponseReason;
public class FinancerServiceException extends RuntimeException {
private final ResponseReason responseReason;
public FinancerServiceException(ResponseReason responseReason) {
this.responseReason = responseReason;
}
public ResponseReason getResponseReason() {
return this.responseReason;
}
}

View File

@@ -0,0 +1,17 @@
package de.financer.service.exception;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
public class RestControllerExceptionAdvisor extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = {FinancerServiceException.class})
public ResponseEntity<String> handleFinancerServiceException(FinancerServiceException ex, WebRequest request) {
return ex.getResponseReason().toResponseEntity();
}
}

View File

@@ -0,0 +1,67 @@
package de.financer.service.parameter;
public class SearchTransactionsParameter {
private String fromAccountKey;
private String toAccountKey;
private String periodId;
private String limit;
private String order;
private String taxRelevant;
private String accountsAnd;
public String getFromAccountKey() {
return fromAccountKey;
}
public void setFromAccountKey(String fromAccountKey) {
this.fromAccountKey = fromAccountKey;
}
public String getToAccountKey() {
return toAccountKey;
}
public void setToAccountKey(String toAccountKey) {
this.toAccountKey = toAccountKey;
}
public String getPeriodId() {
return periodId;
}
public void setPeriodId(String periodId) {
this.periodId = periodId;
}
public String getLimit() {
return limit;
}
public void setLimit(String limit) {
this.limit = limit;
}
public String getOrder() {
return order;
}
public void setOrder(String order) {
this.order = order;
}
public String getTaxRelevant() {
return taxRelevant;
}
public void setTaxRelevant(String taxRelevant) {
this.taxRelevant = taxRelevant;
}
public String getAccountsAnd() {
return accountsAnd;
}
public void setAccountsAnd(String accountsAnd) {
this.accountsAnd = accountsAnd;
}
}

View File

@@ -14,7 +14,6 @@ import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Locale;
import java.util.stream.Collectors;
@Component
@@ -52,7 +51,7 @@ public class SendRecurringTransactionReminderTask {
// TODO Filtering currently happens in memory but should be done via SQL
recurringTransactions = IterableUtils.toList(recurringTransactions)
.stream()
.filter((rt) -> rt.isRemind())
.filter(RecurringTransaction::isRemind)
.collect(Collectors.toList());
LOGGER.info(String
@@ -65,20 +64,18 @@ public class SendRecurringTransactionReminderTask {
.append(System.lineSeparator())
.append(System.lineSeparator());
IterableUtils.toList(recurringTransactions).forEach((rt) -> {
reminderBuilder.append(rt.getId())
.append("|")
.append(rt.getDescription())
.append(System.lineSeparator())
.append(this.getMessage("financer.recurring-transaction.email.reminder.from"))
.append(rt.getFromAccount().getKey())
.append(this.getMessage("financer.recurring-transaction.email.reminder.to"))
.append(rt.getToAccount().getKey())
.append(": ")
.append(rt.getAmount().toString())
.append(System.lineSeparator())
.append(System.lineSeparator());
});
IterableUtils.toList(recurringTransactions).forEach((rt) -> reminderBuilder.append(rt.getId())
.append("|")
.append(rt.getDescription())
.append(System.lineSeparator())
.append(this.getMessage("financer.recurring-transaction.email.reminder.from"))
.append(rt.getFromAccount().getKey())
.append(this.getMessage("financer.recurring-transaction.email.reminder.to"))
.append(rt.getToAccount().getKey())
.append(": ")
.append(rt.getAmount().toString())
.append(System.lineSeparator())
.append(System.lineSeparator()));
final SimpleMailMessage msg = new SimpleMailMessage();

View File

@@ -3,7 +3,6 @@ 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;

View File

@@ -46,7 +46,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid",
"account.invalid",
Long.valueOf(150l),
150L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -67,7 +67,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.invalid",
Long.valueOf(150l),
150L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -88,7 +88,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.invalid",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -110,7 +110,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -154,7 +154,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(0l),
0L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -176,7 +176,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
null,
"INTERVAL_TYPE",
@@ -198,7 +198,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
"HOLIDAY_WEEKEND_TYPE",
"INTERVAL_TYPE",
@@ -220,7 +220,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
null,
@@ -242,7 +242,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
"INTERVAL_TYPE",
@@ -264,7 +264,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
IntervalType.DAILY.name(),
@@ -286,7 +286,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
IntervalType.DAILY.name(),
@@ -308,7 +308,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
IntervalType.DAILY.name(),
@@ -331,7 +331,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
IntervalType.DAILY.name(),
@@ -353,7 +353,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
// Act
final ResponseReason response = this.classUnderTest.createRecurringTransaction("account.from",
"account.to",
Long.valueOf(150l),
150L,
"DESCRIPTION",
HolidayWeekendType.SAME_DAY.name(),
IntervalType.DAILY.name(),
@@ -369,7 +369,7 @@ public class RecurringTransactionService_createRecurringTransactionTest {
private Account createAccount() {
final Account account = new Account();
account.setCurrentBalance(Long.valueOf(0l));
account.setCurrentBalance(0L);
return account;
}

View File

@@ -48,7 +48,7 @@ public class TransactionService_createTransactionTest {
// will not be found.
// Act
final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", Long.valueOf(150l), "24.02.2019", "XXX", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", 150L, "24.02.2019", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.FROM_AND_TO_ACCOUNT_NOT_FOUND, response);
@@ -60,7 +60,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.invalid", 150L, "24.02.2019", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.TO_ACCOUNT_NOT_FOUND, response);
@@ -72,7 +72,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.to", 150L, "24.02.2019", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.FROM_ACCOUNT_NOT_FOUND, response);
@@ -85,7 +85,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 150L, "24.02.2019", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.INVALID_BOOKING_ACCOUNTS, response);
@@ -111,7 +111,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 0L, "24.02.2019", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.AMOUNT_ZERO, response);
@@ -124,7 +124,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, null, "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.MISSING_DATE, response);
@@ -137,7 +137,7 @@ public class TransactionService_createTransactionTest {
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", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, "2019-01-01", "XXX", false);
// Assert
Assert.assertEquals(ResponseReason.INVALID_DATE_FORMAT, response);
@@ -150,24 +150,24 @@ public class TransactionService_createTransactionTest {
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));
Mockito.when(this.ruleService.getMultiplierFromAccount(Mockito.any())).thenReturn(-1L);
Mockito.when(this.ruleService.getMultiplierToAccount(Mockito.any())).thenReturn(1L);
Mockito.when(fromAccount.getCurrentBalance()).thenReturn(0L);
Mockito.when(toAccount.getCurrentBalance()).thenReturn(0L);
// Act
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", Long.valueOf(125l), "24.02.2019", "XXX", false);
final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, "24.02.2019", "XXX", false);
// 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));
Assert.assertEquals(ResponseReason.CREATED, response);
Mockito.verify(fromAccount, Mockito.times(1)).setCurrentBalance((long) -125);
Mockito.verify(toAccount, Mockito.times(1)).setCurrentBalance(125L);
}
private Account createAccount() {
final Account account = new Account();
account.setCurrentBalance(Long.valueOf(0l));
account.setCurrentBalance(0L);
return account;
}

View File

@@ -13,7 +13,6 @@ import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.List;
import java.util.Optional;
@RunWith(MockitoJUnitRunner.class)
@@ -122,10 +121,10 @@ public class TransactionService_deleteTransactionTest {
transaction.setFromAccount(fromAccount);
transaction.setToAccount(toAccount);
transaction.setAmount(Long.valueOf(10000L));
transaction.setAmount(10000L);
fromAccount.setCurrentBalance(Long.valueOf(40000L));
toAccount.setCurrentBalance(Long.valueOf(15000L));
fromAccount.setCurrentBalance(40000L);
toAccount.setCurrentBalance(15000L);
fromAccount.setType(fromType);
toAccount.setType(toType);

View File

@@ -4,7 +4,6 @@ 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;
@@ -40,10 +39,10 @@ public class SendRecurringTransactionReminderTaskTest {
public void test_sendReminder() {
// Arrange
final Collection<RecurringTransaction> recurringTransactions = Arrays.asList(
createRecurringTransaction("Test booking 1", "Income", "accounts.bank", Long.valueOf(250000), true),
createRecurringTransaction("Test booking 2", "Bank", "accounts.rent", Long.valueOf(41500), true),
createRecurringTransaction("Test booking 3", "Bank", "accounts.cash", Long.valueOf(5000), true),
createRecurringTransaction("Test booking 4", "Car", "accounts.car", Long.valueOf(1234), false)
createRecurringTransaction("Test booking 1", "Income", "accounts.bank", 250000L, true),
createRecurringTransaction("Test booking 2", "Bank", "accounts.rent", 41500L, true),
createRecurringTransaction("Test booking 3", "Bank", "accounts.cash", 5000L, true),
createRecurringTransaction("Test booking 4", "Car", "accounts.car", 1234L, false)
);
Mockito.when(this.recurringTransactionService.getAllDueToday()).thenReturn(recurringTransactions);

View File

@@ -4,8 +4,6 @@ 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;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@SpringBootApplication
public class FinancerApplication extends SpringBootServletInitializer {

View File

@@ -12,18 +12,18 @@ public enum ChartType {
EXPENSE_PERIOD_TOTALS_CURRENT_YEAR(false),
EXPENSES_ALL_PERIODS_INLINE(true);
private boolean inline;
private final boolean inline;
ChartType(boolean inline) {
this.inline = inline;
}
public static List<String> valueList(boolean filterInline) {
return Arrays.stream(ChartType.values()).filter((ct) -> filterInline ? !ct.inline : true).map(ChartType::name)
return Arrays.stream(ChartType.values()).filter((ct) -> !filterInline || !ct.inline).map(ChartType::name)
.collect(Collectors.toList());
}
public static ChartType getByValue(String value) {
return Arrays.asList(values()).stream().filter((ct) -> ct.name().equals(value)).findFirst().get();
return Arrays.stream(values()).filter((ct) -> ct.name().equals(value)).findFirst().get();
}
}

View File

@@ -2,10 +2,8 @@ package de.financer.chart.impl.expense;
import de.financer.config.FinancerConfig;
import de.financer.dto.AccountExpense;
import de.financer.dto.AccountGroupExpense;
import de.financer.template.GetAccountExpensesCurrentExpensePeriodTemplate;
import de.financer.template.GetAccountExpensesTemplate;
import de.financer.template.GetAccountGroupExpensesTemplate;
import org.apache.commons.collections4.IterableUtils;
import org.jfree.data.general.DefaultPieDataset;
import org.jfree.data.general.PieDataset;
@@ -24,7 +22,7 @@ public class AccountExpensesGenerator extends AbstractExpensesGenerator {
final DefaultPieDataset dataSet = new DefaultPieDataset();
IterableUtils.toList(expenses).stream()
IterableUtils.toList(expenses)
.forEach((ex) -> dataSet.setValue(ex.getAccount().getKey(), (ex.getExpense() / 100D)));
return dataSet;

View File

@@ -22,7 +22,7 @@ public class AccountGroupExpensesGenerator extends AbstractExpensesGenerator {
final DefaultPieDataset dataSet = new DefaultPieDataset();
IterableUtils.toList(expenses).stream()
IterableUtils.toList(expenses)
.forEach((ex) -> dataSet.setValue(ex.getAccountGroup().getName(), (ex.getExpense() / 100D)));
return dataSet;

View File

@@ -39,7 +39,7 @@ public class ExpensesAllPeriodsGenerator extends AbstractChartGenerator<EmptyPar
final List<Long> totalData = new GetExpensesAllPeriodsTemplate().exchange(this.getFinancerConfig()).getBody();
final AtomicInteger counter = new AtomicInteger();
totalData.stream().forEach((l) -> result.addValue(l, "c", "r" + counter.incrementAndGet()));
totalData.forEach((l) -> result.addValue(l, "c", "r" + counter.incrementAndGet()));
return result;
}

View File

@@ -56,7 +56,7 @@ public class PeriodTotalGenerator extends AbstractChartGenerator<PeriodTotalPara
IterableUtils.toList(totalData).stream()
.sorted(Comparator.comparing((ExpensePeriodTotal ept) -> ept.getPeriod().getStart())
.thenComparing((ExpensePeriodTotal ept) -> ept.getType()))
.thenComparing(ExpensePeriodTotal::getType))
.forEach((ept) -> result.addValue((ept.getTotal() / 100D),
this.getMessage("financer.account-type." + ept.getType()),
formatDateY(ept.getPeriod())));

View File

@@ -3,12 +3,14 @@ package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.decorator.AccountDecorator;
import de.financer.template.*;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.form.NewAccountForm;
import de.financer.model.*;
import de.financer.template.*;
import de.financer.template.exception.FinancerRestException;
import de.financer.util.ControllerUtils;
import de.financer.util.TransactionUtils;
import de.financer.util.comparator.TransactionByDateByIdDescComparator;
import org.apache.commons.collections4.IterableUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -57,7 +59,7 @@ public class AccountController {
}
private Iterable<AccountDecorator> decorateAccounts(List<Account> accounts) {
return accounts.stream().map((a) -> new AccountDecorator(a)).collect(Collectors.toList());
return accounts.stream().map(AccountDecorator::new).collect(Collectors.toList());
}
@GetMapping("/newAccount")
@@ -102,21 +104,40 @@ public class AccountController {
return "redirect:/accountOverview";
}
public static void _accountDetails(String key, Model model, FinancerConfig financerConfig) {
try {
final Iterable<SearchTransactionsResponseDto> response =
new SearchTransactionsTemplate()
.exchangeGet(financerConfig, key, key, null, null,
Order.TRANSACTIONS_BY_DATE_DESC, null, false);
final List<SearchTransactionsResponseDto> transactions = IterableUtils.toList(response);
final Account account = IterableUtils.toList(response).stream()
.filter(t -> t.getFromAccount().getKey().equals(key))
.findFirst()
.map(SearchTransactionsResponseDto::getFromAccount)
.orElseGet(() -> transactions
.stream()
.filter(t -> t.getToAccount().getKey().equals(key))
.findFirst()
.map(SearchTransactionsResponseDto::getToAccount).get());
transactions.forEach((t) -> TransactionUtils.adjustAmount(t, account));
model.addAttribute("account", account);
model.addAttribute("transactions", transactions);
model.addAttribute("showActions", true);
model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus()));
}
catch(FinancerRestException e) {
// TODO probably leads to follow-up exceptions during thymeleaf parsing as relevant information are missing
}
}
@GetMapping("/accountDetails")
public String accountDetails(String key, Model model) {
final ResponseEntity<Account> response = new GetAccountByKeyTemplate().exchange(this.financerConfig, key);
final Account account = response.getBody();
final ResponseEntity<Iterable<Transaction>> transactionResponse = new GetAllTransactionsForAccountTemplate()
.exchange(this.financerConfig, account.getKey());
_accountDetails(key, model, this.financerConfig);
List<Transaction> transactions = IterableUtils.toList(transactionResponse.getBody());
transactions.sort(new TransactionByDateByIdDescComparator());
transactions.stream().forEach((t) -> TransactionUtils.adjustAmount(t, account));
model.addAttribute("account", account);
model.addAttribute("transactions", transactions);
model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus()));
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
@@ -134,19 +155,8 @@ public class AccountController {
final ResponseReason responseReason = ResponseReason.fromResponseEntity(closeResponse);
if (!ResponseReason.OK.equals(responseReason)) {
final ResponseEntity<Account> response = new GetAccountByKeyTemplate().exchange(this.financerConfig, key);
final Account account = response.getBody();
final ResponseEntity<Iterable<Transaction>> transactionResponse = new GetAllTransactionsForAccountTemplate()
.exchange(this.financerConfig, account.getKey());
_accountDetails(key, model, this.financerConfig);
List<Transaction> transactions = IterableUtils.toList(transactionResponse.getBody());
transactions.sort(new TransactionByDateByIdDescComparator());
transactions.stream().forEach((t) -> TransactionUtils.adjustAmount(t, account));
model.addAttribute("account", account);
model.addAttribute("transactions", transactions);
model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus()));
model.addAttribute("errorMessage", responseReason.name());
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
@@ -168,19 +178,8 @@ public class AccountController {
final ResponseReason responseReason = ResponseReason.fromResponseEntity(closeResponse);
if (!ResponseReason.OK.equals(responseReason)) {
final ResponseEntity<Account> response = new GetAccountByKeyTemplate().exchange(this.financerConfig, key);
final Account account = response.getBody();
final ResponseEntity<Iterable<Transaction>> transactionResponse = new GetAllTransactionsForAccountTemplate()
.exchange(this.financerConfig, account.getKey());
_accountDetails(key, model, this.financerConfig);
List<Transaction> transactions = IterableUtils.toList(transactionResponse.getBody());
transactions.sort(new TransactionByDateByIdDescComparator());
transactions.stream().forEach((t) -> TransactionUtils.adjustAmount(t, account));
model.addAttribute("account", account);
model.addAttribute("transactions", transactions);
model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus()));
model.addAttribute("errorMessage", responseReason.name());
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);

View File

@@ -15,10 +15,10 @@ public enum Function {
ACC_GP_GET_ACC_GP_EXPENSES("accountGroups/getAccountGroupExpenses"),
ACC_GP_GET_ACC_GP_EXPENSES_CURRENT_EXPENSE_PERIOD("accountGroups/getAccountGroupExpensesCurrentExpensePeriod"),
TR_GET_ALL("transactions/getAll"),
TR_GET_ALL_FOR_ACCOUNT("transactions/getAllForAccount"),
TR_CREATE_TRANSACTION("transactions/createTransaction"),
TR_DELETE_TRANSACTION("transactions/deleteTransaction"),
TR_SEARCH("transactions"),
TR_SEARCH_BY_FQL("transactionsByFql"),
TR_CREATE_TRANSACTION("transactions"),
TR_DELETE_TRANSACTION("transactions/"),
TR_EXPENSES_CURRENT_PERIOD("transactions/getExpensesCurrentPeriod"),
TR_EXPENSE_PERIOD_TOTALS("transactions/getExpensePeriodTotals"),
TR_EXPENSES_ALL_PERIODS("transactions/getExpensesAllPeriods"),
@@ -34,7 +34,7 @@ public enum Function {
P_GET_CURRENT_EXPENSE_PERIOD("periods/getCurrentExpensePeriod"),
P_CLOSE_CURRENT_EXPENSE_PERIOD("periods/closeCurrentExpensePeriod");
private String path;
private final String path;
Function(String path) {
this.path = path;

View File

@@ -2,21 +2,17 @@ package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.dto.SaveTransactionRequestDto;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.form.SearchTransactionsForm;
import de.financer.model.AccountType;
import de.financer.template.GetAccountByKeyTemplate;
import de.financer.template.GetAllAccountsTemplate;
import de.financer.template.GetAllTransactionsForAccountTemplate;
import de.financer.template.StringTemplate;
import de.financer.template.*;
import de.financer.form.NewTransactionForm;
import de.financer.model.Account;
import de.financer.model.AccountStatus;
import de.financer.model.Transaction;
import de.financer.template.exception.FinancerRestException;
import de.financer.util.ControllerUtils;
import de.financer.util.TransactionUtils;
import de.financer.util.comparator.TransactionByDateByIdDescComparator;
import org.apache.commons.collections4.IterableUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -24,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Controller
@@ -32,20 +29,77 @@ public class TransactionController {
@Autowired
private FinancerConfig financerConfig;
@GetMapping("/searchTransactions")
public String searchTransaction(Model model) {
model.addAttribute("form", new SearchTransactionsForm());
model.addAttribute("showActions", false);
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
return "transaction/searchTransactions";
}
@PostMapping("/searchTransactions")
public String searchTransactions(SearchTransactionsForm form, Model model) {
try {
final UriComponentsBuilder transactionBuilder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(this.financerConfig, Function.TR_SEARCH_BY_FQL));
transactionBuilder.queryParam("fql", form.getFql());
final Iterable<SearchTransactionsResponseDto> trxs = FinancerRestTemplate.exchangeGet(transactionBuilder,
new ParameterizedTypeReference<Iterable<SearchTransactionsResponseDto>>() {
});
model.addAttribute("transactions", trxs);
}
catch(FinancerRestException e) {
// TODO
model.addAttribute("errorMessage", e.getResponseReason().name());
}
model.addAttribute("form", form);
model.addAttribute("showActions", false);
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
return "transaction/searchTransactions";
}
@GetMapping("/newTransaction")
public String newTransaction(Model model) {
final ResponseEntity<Iterable<Account>> response = new GetAllAccountsTemplate().exchange(this.financerConfig);
final List<Account> fromAccounts = ControllerUtils.filterAndSortAccounts(response.getBody()).stream()
.filter((a) -> a.getType() != AccountType.EXPENSE)
.collect(Collectors.toList());
final List<Account> toAccounts = ControllerUtils.filterAndSortAccounts(response.getBody()).stream()
.filter((a) -> a.getType() != AccountType.INCOME && a
.getType() != AccountType.START)
.collect(Collectors.toList());
return _newTransaction(model, new NewTransactionForm(), Optional.empty());
}
private String _newTransaction(Model model, NewTransactionForm form, Optional<ResponseReason> responseReason) {
try {
final Iterable<Account> allAccounts =
FinancerRestTemplate.exchangeGet(this.financerConfig,
Function.ACC_GET_ALL,
new ParameterizedTypeReference<Iterable<Account>>() {});
final List<Account> fromAccounts = ControllerUtils.filterAndSortAccounts(allAccounts).stream()
.filter((a) -> a.getType() != AccountType.EXPENSE)
.collect(Collectors.toList());
final List<Account> toAccounts = ControllerUtils.filterAndSortAccounts(allAccounts).stream()
.filter((a) -> a.getType() != AccountType.INCOME && a
.getType() != AccountType.START)
.collect(Collectors.toList());
model.addAttribute("fromAccounts", fromAccounts);
model.addAttribute("toAccounts", toAccounts);
}
catch(FinancerRestException e) {
// Nothing to do
// This is very unlikely to happen and if it happens the account selection stays empty, so the user
// cannot create a transaction anyway and is forced to reload the page or navigate back
}
model.addAttribute("form", form);
responseReason.ifPresent(rr -> model.addAttribute("errorMessage", rr.name()));
model.addAttribute("fromAccounts", fromAccounts);
model.addAttribute("toAccounts", toAccounts);
model.addAttribute("form", new NewTransactionForm());
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
@@ -54,36 +108,20 @@ public class TransactionController {
@PostMapping("/saveTransaction")
public String saveTransaction(NewTransactionForm form, Model model) {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(this.financerConfig, Function.TR_CREATE_TRANSACTION))
.queryParam("fromAccountKey", form.getFromAccountKey())
.queryParam("toAccountKey", form.getToAccountKey())
.queryParam("amount", form.getAmount())
.queryParam("date", ControllerUtils.formatDate(this.financerConfig, form.getDate()))
.queryParam("description", form.getDescription())
.queryParam("taxRelevant", form.getTaxRelevant());
final SaveTransactionRequestDto requestDto = new SaveTransactionRequestDto();
final ResponseEntity<String> response = new StringTemplate().exchange(builder);
final ResponseReason responseReason = ResponseReason.fromResponseEntity(response);
requestDto.setFromAccountKey(form.getFromAccountKey());
requestDto.setToAccountKey(form.getToAccountKey());
requestDto.setAmount(form.getAmount());
requestDto.setDate(ControllerUtils.formatDate(this.financerConfig, form.getDate()));
requestDto.setDescription(form.getDescription());
requestDto.setTaxRelevant(form.getTaxRelevant());
if (!ResponseReason.OK.equals(responseReason)) {
final ResponseEntity<Iterable<Account>> accRes = new GetAllAccountsTemplate().exchange(this.financerConfig);
final List<Account> fromAccounts = ControllerUtils.filterAndSortAccounts(accRes.getBody()).stream()
.filter((a) -> a.getType() != AccountType.EXPENSE)
.collect(Collectors.toList());
final List<Account> toAccounts = ControllerUtils.filterAndSortAccounts(accRes.getBody()).stream()
.filter((a) -> a.getType() != AccountType.INCOME && a
.getType() != AccountType.START)
.collect(Collectors.toList());
final ResponseReason responseReason = FinancerRestTemplate
.exchangePost(this.financerConfig, Function.TR_CREATE_TRANSACTION, requestDto);
model.addAttribute("fromAccounts", fromAccounts);
model.addAttribute("toAccounts", toAccounts);
model.addAttribute("form", form);
model.addAttribute("errorMessage", responseReason.name());
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
return "transaction/newTransaction";
if (!ResponseReason.CREATED.equals(responseReason)) {
return _newTransaction(model, form, Optional.of(responseReason));
}
return "redirect:/accountOverview";
@@ -91,34 +129,16 @@ public class TransactionController {
@GetMapping("/deleteTransaction")
public String deleteTransaction(String transactionId, String accountKey, Model model) {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(this.financerConfig, Function.TR_DELETE_TRANSACTION))
.queryParam("transactionId", transactionId);
final ResponseReason responseReason = FinancerRestTemplate
.exchangeDelete(this.financerConfig, Function.TR_DELETE_TRANSACTION, transactionId);
final ResponseEntity<String> response = new StringTemplate().exchange(builder);
final ResponseReason responseReason = ResponseReason.fromResponseEntity(response);
AccountController._accountDetails(accountKey, model, this.financerConfig);
final ResponseEntity<Account> accountResponse = new GetAccountByKeyTemplate()
.exchange(this.financerConfig, accountKey);
final Account account = accountResponse.getBody();
final ResponseEntity<Iterable<Transaction>> transactionResponse = new GetAllTransactionsForAccountTemplate()
.exchange(this.financerConfig, account.getKey());
List<Transaction> transactions = IterableUtils.toList(transactionResponse.getBody());
transactions.sort(new TransactionByDateByIdDescComparator());
transactions.stream().forEach((t) -> TransactionUtils.adjustAmount(t, account));
model.addAttribute("account", account);
model.addAttribute("transactions", transactions);
model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus()));
ControllerUtils.addVersionAttribute(model, this.financerConfig);
ControllerUtils.addCurrencySymbol(model, this.financerConfig);
if (!ResponseReason.OK.equals(responseReason)) {
model.addAttribute("errorMessage", responseReason.name());
return "account/accountDetails";
}
return "account/accountDetails";

View File

@@ -1,18 +0,0 @@
package de.financer.controller.handler;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.ResponseErrorHandler;
import java.io.IOException;
public class NoExceptionResponseErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
}
}

View File

@@ -41,7 +41,7 @@ public class AccountDecorator {
.findFirst()
.orElseGet(() -> {
AccountStatistic as = new AccountStatistic();
as.setTransactionCountTo(0l);
as.setTransactionCountTo(0L);
return as;
})
.getSpendingTotalTo();
@@ -55,9 +55,9 @@ public class AccountDecorator {
return Math.round(this.account.getAccountStatistics().stream()
.filter((as) -> as.getPeriod().getType().equals(PeriodType.EXPENSE) && as.getPeriod()
.getEnd() != null)
.mapToLong((as) -> as.getSpendingTotalTo())
.mapToLong(AccountStatistic::getSpendingTotalTo)
.average()
.orElseGet(() -> Double.valueOf(0d)));
.orElseGet(() -> 0d));
}
return null;

View File

@@ -0,0 +1,13 @@
package de.financer.form;
public class SearchTransactionsForm {
private String fql;
public String getFql() {
return fql;
}
public void setFql(String fql) {
this.fql = fql;
}
}

View File

@@ -1,19 +1,133 @@
package de.financer.template;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.financer.controller.handler.NoExceptionResponseErrorHandler;
import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.template.exception.FinancerRestException;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
public class FinancerRestTemplate<T> {
public ResponseEntity<T> exchange(String url, ParameterizedTypeReference<T> type) {
// Requests that result in the return of a String allow simplified error handling
private static <R, B> ResponseEntity<R> _exchangePost(FinancerConfig financerConfig,
Function endpoint,
B body,
Class<R> type) throws FinancerRestException {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, endpoint));
final RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new NoExceptionResponseErrorHandler());
try {
return restTemplate.exchange(builder.toUriString(), HttpMethod.POST, new HttpEntity<>(body), type);
} catch (HttpStatusCodeException e) {
final ResponseEntity<String> exceptionResponse = new ResponseEntity<>(e.getResponseBodyAsString(), e
.getStatusCode());
throw new FinancerRestException(ResponseReason.fromResponseEntity(exceptionResponse));
}
}
public static <R, B> R exchangePost(FinancerConfig financerConfig,
Function endpoint,
B body,
Class<R> type) throws FinancerRestException {
return FinancerRestTemplate._exchangePost(financerConfig, endpoint, body, type).getBody();
}
public static <B> ResponseReason exchangePost(FinancerConfig financerConfig,
Function endpoint,
B body) {
ResponseReason response;
try {
final ResponseEntity<String> responseEntity = FinancerRestTemplate
._exchangePost(financerConfig, endpoint, body, String.class);
response = ResponseReason.fromResponseEntity(responseEntity);
} catch (FinancerRestException e) {
response = e.getResponseReason();
}
return response;
}
public static ResponseReason exchangeDelete(FinancerConfig financerConfig,
Function endpoint,
String id
) {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, endpoint, id));
final RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response;
try {
response = restTemplate.exchange(builder.toUriString(), HttpMethod.DELETE, null, String.class);
} catch (HttpStatusCodeException e) {
response = new ResponseEntity<>(e.getResponseBodyAsString(), e.getStatusCode());
}
return ResponseReason.fromResponseEntity(response);
}
public static <R> R exchangeGet(FinancerConfig financerConfig,
Function endpoint,
Class<R> type) throws FinancerRestException {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, endpoint));
final RestTemplate restTemplate = new RestTemplate();
try {
return restTemplate.exchange(builder.toUriString(), HttpMethod.GET, null, type).getBody();
} catch (HttpStatusCodeException e) {
final ResponseEntity<String> exceptionResponse = new ResponseEntity<>(e.getResponseBodyAsString(), e
.getStatusCode());
throw new FinancerRestException(ResponseReason.fromResponseEntity(exceptionResponse));
}
}
public static <R> R exchangeGet(FinancerConfig financerConfig,
Function endpoint,
ParameterizedTypeReference<R> type) throws FinancerRestException {
final UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, endpoint));
final RestTemplate restTemplate = new RestTemplate();
try {
return restTemplate.exchange(builder.toUriString(), HttpMethod.GET, null, type).getBody();
} catch (HttpStatusCodeException e) {
final ResponseEntity<String> exceptionResponse = new ResponseEntity<>(e.getResponseBodyAsString(), e
.getStatusCode());
throw new FinancerRestException(ResponseReason.fromResponseEntity(exceptionResponse));
}
}
public static <R> R exchangeGet(UriComponentsBuilder builder,
ParameterizedTypeReference<R> type) throws FinancerRestException {
final RestTemplate restTemplate = new RestTemplate();
try {
return restTemplate.exchange(builder.toUriString(), HttpMethod.GET, null, type).getBody();
} catch (HttpStatusCodeException e) {
final ResponseEntity<String> exceptionResponse = new ResponseEntity<>(e.getResponseBodyAsString(), e
.getStatusCode());
throw new FinancerRestException(ResponseReason.fromResponseEntity(exceptionResponse));
}
}
// ---------------------------------- LEGACY
public ResponseEntity<T> exchange(String url, ParameterizedTypeReference<T> type) {
final RestTemplate restTemplate = new RestTemplate();
return restTemplate.exchange(url, HttpMethod.GET, null, type);
}
@@ -21,22 +135,6 @@ public class FinancerRestTemplate<T> {
public ResponseEntity<T> exchange(String url, Class<T> type) {
final RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new NoExceptionResponseErrorHandler());
return restTemplate.exchange(url, HttpMethod.GET, null, type);
}
// The spring.jackson. properties are not picked up by the RestTemplate and its converter,
// so we need to overwrite it here
private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
jsonConverter.setObjectMapper(objectMapper);
return jsonConverter;
}
}

View File

@@ -3,7 +3,6 @@ package de.financer.template;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.dto.AccountExpense;
import de.financer.dto.AccountGroupExpense;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;

View File

@@ -3,8 +3,6 @@ package de.financer.template;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.dto.AccountGroupExpense;
import de.financer.model.Account;
import de.financer.model.Transaction;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;

View File

@@ -1,21 +0,0 @@
package de.financer.template;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.model.Transaction;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.web.util.UriComponentsBuilder;
public class GetAllTransactionsForAccountTemplate {
public ResponseEntity<Iterable<Transaction>> exchange(FinancerConfig financerConfig, String accountKey) {
final UriComponentsBuilder transactionBuilder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, Function.TR_GET_ALL_FOR_ACCOUNT))
.queryParam("accountKey", accountKey);
return new FinancerRestTemplate<Iterable<Transaction>>()
.exchange(transactionBuilder.toUriString(), new ParameterizedTypeReference<Iterable<Transaction>>() {
});
}
}

View File

@@ -2,7 +2,6 @@ package de.financer.template;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;

View File

@@ -0,0 +1,52 @@
package de.financer.template;
import de.financer.config.FinancerConfig;
import de.financer.controller.Function;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.template.exception.FinancerRestException;
import de.financer.util.ControllerUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.util.UriComponentsBuilder;
public class SearchTransactionsTemplate {
public Iterable<SearchTransactionsResponseDto> exchangeGet(FinancerConfig financerConfig,
String fromAccountKey,
String toAccountKey,
Long periodId,
Integer limit,
Order order,
Boolean taxRelevant,
boolean accountsAnd) throws FinancerRestException {
final UriComponentsBuilder transactionBuilder = UriComponentsBuilder
.fromHttpUrl(ControllerUtils.buildUrl(financerConfig, Function.TR_SEARCH));
if (fromAccountKey != null) {
transactionBuilder.queryParam("fromAccountKey", fromAccountKey);
}
if (fromAccountKey != null) {
transactionBuilder.queryParam("toAccountKey", toAccountKey);
}
if (periodId != null) {
transactionBuilder.queryParam("periodId", periodId);
}
if (limit != null) {
transactionBuilder.queryParam("limit", limit);
}
if (order != null) {
transactionBuilder.queryParam("order", order.name());
}
if (taxRelevant != null) {
transactionBuilder.queryParam("taxRelevant", taxRelevant);
}
transactionBuilder.queryParam("accountsAnd", accountsAnd);
return FinancerRestTemplate.exchangeGet(transactionBuilder, new ParameterizedTypeReference<Iterable<SearchTransactionsResponseDto>>() {});
}
}

View File

@@ -0,0 +1,15 @@
package de.financer.template.exception;
import de.financer.ResponseReason;
public class FinancerRestException extends Exception {
private final ResponseReason responseReason;
public FinancerRestException(ResponseReason responseReason) {
this.responseReason = responseReason;
}
public ResponseReason getResponseReason() {
return this.responseReason;
}
}

View File

@@ -24,6 +24,10 @@ public class ControllerUtils {
return String.format("%s%s", financerConfig.getServerUrl(), function.getPath());
}
public static String buildUrl(FinancerConfig financerConfig, Function function, String id) {
return String.format("%s%s%s", financerConfig.getServerUrl(), function.getPath(), id);
}
public static List<Account> filterAndSortAccounts(Iterable<Account> accounts) {
return filterAndSortAccounts(accounts, false);
}

View File

@@ -1,13 +1,13 @@
package de.financer.util;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.model.Account;
import de.financer.model.AccountType;
import de.financer.model.Transaction;
import static de.financer.model.AccountType.*;
public class TransactionUtils {
public static final void adjustAmount(Transaction t, Account account) {
public static void adjustAmount(SearchTransactionsResponseDto t, Account account) {
boolean isFrom = t.getFromAccount().getKey().equals(account.getKey());
if (AccountType.START.equals(t.getFromAccount().getType()) && AccountType.LIABILITY.equals(t.getToAccount().getType())) {

View File

@@ -4,6 +4,7 @@ financer.account-overview.available-actions.show-closed=Show closed accounts
financer.account-overview.available-actions.hide-closed=Hide closed accounts
financer.account-overview.available-actions.create-account=Create new account
financer.account-overview.available-actions.create-transaction=Create new transaction
financer.account-overview.available-actions.search-transactions=Search transactions
financer.account-overview.available-actions.create-recurring-transaction=Create new recurring transaction
financer.account-overview.available-actions.recurring-transaction-all=Show all recurring transactions
financer.account-overview.available-actions.create-account-group=Create new account group
@@ -98,28 +99,34 @@ financer.account-details.available-actions.close-account=Close account
financer.account-details.available-actions.open-account=Open account
financer.account-details.available-actions.back-to-overview=Back to overview
financer.account-details.available-actions.create-transaction=Create new transaction
financer.account-details.table-header.id=ID
financer.account-details.table-header.fromAccount=From account
financer.account-details.table-header.toAccount=To account
financer.account-details.table-header.date=Date
financer.account-details.table-header.amount=Amount
financer.account-details.table-header.description=Description
financer.account-details.table-header.byRecurring=Recurring
financer.account-details.table-header.taxRelevant=Tax relevant
financer.transaction-list.table-header.id=ID
financer.transaction-list.table-header.fromAccount=From account
financer.transaction-list.table-header.toAccount=To account
financer.transaction-list.table-header.date=Date
financer.transaction-list.table-header.amount=Amount
financer.transaction-list.table-header.description=Description
financer.transaction-list.table-header.byRecurring=Recurring
financer.transaction-list.table-header.taxRelevant=Tax relevant
financer.account-details.details.type=Type\:
financer.account-details.details.balance=Current balance\:
financer.account-details.details.group=Group\:
financer.account-details.table-header.actions=Actions
financer.account-details.table.actions.deleteTransaction=Delete
financer.account-details.table.recurring.yes=Yes
financer.account-details.table.recurring.no=No
financer.account-details.table.taxRelevant.true=Yes
financer.account-details.table.taxRelevant.false=No
financer.transaction-list.table-header.actions=Actions
financer.transaction-list.table.actions.deleteTransaction=Delete
financer.transaction-list.table.recurring.yes=Yes
financer.transaction-list.table.recurring.no=No
financer.transaction-list.table.taxRelevant.true=Yes
financer.transaction-list.table.taxRelevant.false=No
financer.recurring-to-transaction-with-amount.title=financer\: create transaction from recurring with amount
financer.recurring-to-transaction-with-amount.label.amount=Amount\:
financer.recurring-to-transaction-with-amount.submit=Create
financer.search-transactions.title=financer\: search transactions
financer.search-transactions.back-to-overview=Back to overview
financer.search-transactions.label.fql=FQL expression\:
financer.search-transactions.submit=Search
financer.search-transactions.show-query-options=Show query options
financer.chart-select.title=Select a chart to generate
financer.chart-select.submit=Select
@@ -166,6 +173,7 @@ financer.heading.recurring-to-transaction-with-amount=financer\: create transact
financer.heading.chart-select=financer\: select a chart to generate
financer.heading.chart-config-account-group-expenses-for-period=financer\: configure account group expenses for period chart
financer.heading.chart-config-account-expenses-for-period=financer\: configure account expenses for period chart
financer.heading.search-transactions=financer\: search transactions
financer.cancel-back-to-overview=Cancel and back to overview

View File

@@ -4,6 +4,7 @@ financer.account-overview.available-actions.show-closed=Zeige auch geschlossene
financer.account-overview.available-actions.hide-closed=Verstecke geschlossene Konten
financer.account-overview.available-actions.create-account=Neues Konto erstellen
financer.account-overview.available-actions.create-transaction=Neue Buchung erstellen
financer.account-overview.available-actions.search-transactions=Buchungen suchen
financer.account-overview.available-actions.create-recurring-transaction=Neue wiederkehrende Buchung erstellen
financer.account-overview.available-actions.recurring-transaction-all=Zeige alle wiederkehrende Buchungen
financer.account-overview.available-actions.create-account-group=Neue Konto-Gruppe erstellen
@@ -18,6 +19,8 @@ financer.account-overview.table-header.id=ID
financer.account-overview.table-header.key=Konto
financer.account-overview.table-header.group=Gruppe
financer.account-overview.table-header.balance=Kontostand
financer.account-overview.table-header.spending-current-period=Ausgaben aktuelle Periode
financer.account-overview.table-header.average-spending-period=Durchschnittliche Ausgaben
financer.account-overview.table-header.type=Typ
financer.account-overview.table-header.status=Status
financer.account-overview.tooltip.status.current-expenses=Periode ab {0}. Durch Klicken des Betrags kann eine grafische \u00DCbersicht über die Ausgaben gruppiert nach Konto-Gruppe angezeigt werden
@@ -96,28 +99,34 @@ financer.account-details.available-actions.close-account=Konto schlie\u00DFen
financer.account-details.available-actions.open-account=Konto \u00F6ffnen
financer.account-details.available-actions.back-to-overview=Zur\u00FCck zur \u00DCbersicht
financer.account-details.available-actions.create-transaction=Neue Buchung erstellen
financer.account-details.table-header.id=ID
financer.account-details.table-header.fromAccount=Von Konto
financer.account-details.table-header.toAccount=An Konto
financer.account-details.table-header.date=Datum
financer.account-details.table-header.amount=Betrag
financer.account-details.table-header.description=Beschreibung
financer.account-details.table-header.byRecurring=Wiederkehrend
financer.account-details.table-header.taxRelevant=Relevant f\u00FCr Steuererkl\u00E4rung
financer.transaction-list.table-header.id=ID
financer.transaction-list.table-header.fromAccount=Von Konto
financer.transaction-list.table-header.toAccount=An Konto
financer.transaction-list.table-header.date=Datum
financer.transaction-list.table-header.amount=Betrag
financer.transaction-list.table-header.description=Beschreibung
financer.transaction-list.table-header.byRecurring=Wiederkehrend
financer.transaction-list.table-header.taxRelevant=Relevant f\u00FCr Steuererkl\u00E4rung
financer.account-details.details.type=Typ\:
financer.account-details.details.balance=Kontostand\:
financer.account-details.details.group=Gruppe\:
financer.account-details.table-header.actions=Aktionen
financer.account-details.table.actions.deleteTransaction=L\u00F6schen
financer.account-details.table.recurring.yes=Ja
financer.account-details.table.recurring.no=Nein
financer.account-details.table.taxRelevant.true=Ja
financer.account-details.table.taxRelevant.false=Nein
financer.transaction-list.table-header.actions=Aktionen
financer.transaction-list.table.actions.deleteTransaction=L\u00F6schen
financer.transaction-list.table.recurring.yes=Ja
financer.transaction-list.table.recurring.no=Nein
financer.transaction-list.table.taxRelevant.true=Ja
financer.transaction-list.table.taxRelevant.false=Nein
financer.recurring-to-transaction-with-amount.title=financer\: Buchung mit Betrag aus wiederkehrender Buchung erstellen
financer.recurring-to-transaction-with-amount.label.amount=Betrag\:
financer.recurring-to-transaction-with-amount.submit=Erstellen
financer.search-transactions.title=financer\: Buchungen suchen
financer.search-transactions.back-to-overview=Zur\u00FCck zur \u00DCbersicht
financer.search-transactions.label.fql=FQL Ausdruck\:
financer.search-transactions.submit=Suchen
financer.search-transactions.show-query-options=Suchoptionen anzeigen
financer.chart-select.title=Ein Diagramm zum Erzeugen ausw\u00E4hlen
financer.chart-select.submit=Ausw\u00E4hlen
@@ -157,12 +166,13 @@ financer.heading.account-new=financer\: Neues Konto erstellen
financer.heading.account-group-new=financer\: Neue Konto-Gruppe erstellen
financer.heading.account-overview=financer\: \u00DCbersicht
financer.heading.account-details=financer\: Kontodetails f\u00FCr {0}
financer.heading.recurring-transaction-list.dueToday=financer\: wiederkehrende Buchungen heute f\u00E4llig
financer.heading.recurring-transaction-list.active=financer\: aktive wiederkehrende Buchungen
financer.heading.recurring-transaction-list.all=financer\: alle wiederkehrende Buchungen
financer.heading.recurring-transaction-list.dueToday=financer\: Wiederkehrende Buchungen heute f\u00E4llig
financer.heading.recurring-transaction-list.active=financer\: Aktive wiederkehrende Buchungen
financer.heading.recurring-transaction-list.all=financer\: Alle wiederkehrende Buchungen
financer.heading.recurring-to-transaction-with-amount=financer\: Buchung mit Betrag aus wiederkehrender Buchung erstellen
financer.heading.chart-select=financer\: ein Diagramm zum Erzeugen ausw\u00E4hlen
financer.heading.chart-config-account-group-expenses-for-period=financer\: konfigurieren von Ausgaben f\u00FCr Periode gruppiert nach Konto-Gruppe Diagramm
financer.heading.chart-select=financer\: Ein Diagramm zum Erzeugen ausw\u00E4hlen
financer.heading.chart-config-account-group-expenses-for-period=financer\: Konfigurieren von Ausgaben f\u00FCr Periode gruppiert nach Konto-Gruppe Diagramm
financer.heading.search-transactions=financer\: Buchungen suchen
financer.cancel-back-to-overview=Abbrechen und zur \u00DCbersicht

View File

@@ -2,7 +2,10 @@ v26 -> v27:
- Changed sort order of accounts in overview page. The accounts are now sorted by the account type first (BCILES), then
by the account group name and then by the account ID, leading to an overall more organic order of accounts
- Add tax relevance flag to transaction and recurring transaction creation. This flag denotes whether a transaction or
the instances of a recurring transaction are relevant for a tax declaration. This is preparation for extended reports.
the instances of a recurring transaction are relevant for a tax declaration. This is preparation for extended reports
- Add searching of transactions via FQL (Financer Query Language)
- Rework /transaction end point to better adhere to REST API requirements (proper HTTP return codes and HTTP method
usage)
v25 -> v26:
- Close of the current expense period now creates null statistic entries for accounts that have not been used in

View File

@@ -12,7 +12,7 @@
/* --------------------- */
#account-overview-table,
#account-transaction-table,
#transaction-table,
#recurring-transaction-list-table {
width: 100%;
border-collapse: collapse;
@@ -23,8 +23,8 @@
#account-overview-table th,
#account-overview-table td,
#account-transaction-table th,
#account-transaction-table td,
#transaction-table th,
#transaction-table td,
#recurring-transaction-list-table th,
#recurring-transaction-list-table td {
border-bottom: 1px solid #ddd;
@@ -33,7 +33,7 @@
}
#account-overview-table th,
#account-transaction-table th,
#transaction-table th,
#recurring-transaction-list-table th {
position: sticky;
top: 0px;
@@ -107,13 +107,18 @@ tr:hover {
#recurring-to-transaction-with-amount-form *,
#new-account-group-form *,
#chart-config-account-group-expenses-for-period-form *,
#chart-config-account-expenses-for-period-form * {
#chart-config-account-expenses-for-period-form *,
#search-transactions-form * {
display: block;
margin-top: 1em;
width: 20em;
box-sizing: border-box;
}
#search-transactions-form > input[type=text] {
width: 100% !important;
}
#chart-select-form div {
width: 20em;
margin-top: 1em;
@@ -187,4 +192,15 @@ input[type=submit] {
#type-row {
width: var(--type-row-width);
padding: 0px !important
}
#search-transactions-fql-detail {
border: 1px solid grey;
border-radius: 0.5em;
padding-inline: 0.3em;
background-color: lightgrey;
}
#search-transactions-fql-detail > * {
font-size: 0.7em;
}

View File

@@ -14,8 +14,9 @@
7. Transactions
8. Recurring transactions
9. Reporting
10. Setup
11. Planned features
10. FQL
11. Setup
12. Planned features
1. About
========
@@ -170,12 +171,15 @@
9. Reporting
============
10. Setup
10. FQL
=======
11. 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.
10.1 Database setup
11.1 Database setup
-------------------
First install PostgreSQL. Then create a user for financer:
sudo -iu postgres
@@ -191,7 +195,7 @@
\q
exit
11. Planned features
12. Planned features
====================
This chapter lists planned features. The list is in no particular order:
- Transaction import from online banking (file based)

View File

@@ -35,36 +35,7 @@
<a th:href="@{/accountOverview}"
th:text="#{financer.account-details.available-actions.back-to-overview}"/>
</div>
<table id="account-transaction-table">
<tr>
<th class="hideable-column" th:text="#{financer.account-details.table-header.id}"/>
<th th:text="#{financer.account-details.table-header.fromAccount}"/>
<th th:text="#{financer.account-details.table-header.toAccount}"/>
<th th:text="#{financer.account-details.table-header.date}"/>
<th th:text="#{financer.account-details.table-header.amount}"/>
<th th:text="#{financer.account-details.table-header.description}"/>
<th th:text="#{financer.account-details.table-header.byRecurring}"/>
<th th:text="#{financer.account-details.table-header.taxRelevant}"/>
<th th:text="#{financer.account-details.table-header.actions}"/>
</tr>
<tr th:each="transaction : ${transactions}">
<td class="hideable-column" th:text="${transaction.id}"/>
<td th:text="${transaction.fromAccount.key}" />
<td th:text="${transaction.toAccount.key}" />
<td th:text="${#temporals.format(transaction.date)}" />
<td th:text="${#numbers.formatDecimal(transaction.amount/100D, 1, 'DEFAULT', 2, 'DEFAULT') + currencySymbol}"/>
<td th:text="${transaction.description}" />
<td th:if="${transaction.recurringTransaction != null}" th:text="#{financer.account-details.table.recurring.yes}" />
<td th:if="${transaction.recurringTransaction == null}" th:text="#{financer.account-details.table.recurring.no}" />
<td th:text="#{'financer.account-details.table.taxRelevant.' + ${transaction.taxRelevant}}" />
<td>
<div id="account-transaction-table-actions-container">
<a th:href="@{/deleteTransaction(transactionId=${transaction.id}, accountKey=${account.key})}"
th:text="#{financer.account-details.table.actions.deleteTransaction}"/>
</div>
</td>
</tr>
</table>
<div th:replace="transaction/transactionList :: transaction-list"/>
<div th:replace="includes/footer :: footer"/>
</body>
</html>

View File

@@ -49,6 +49,7 @@
</div>
<div id="action-container-sub-transactions">
<a th:href="@{/newTransaction}" th:text="#{financer.account-overview.available-actions.create-transaction}"/>
<a th:href="@{/searchTransactions}" th:text="#{financer.account-overview.available-actions.search-transactions}"/>
</div>
<div id="action-container-sub-recurring-transactions">
<a th:href="@{/newRecurringTransaction}"

View File

@@ -0,0 +1,61 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{financer.search-transactions.title}"/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.search-transactions}" />
<span class="errorMessage" th:if="${errorMessage != null}" th:text="#{'financer.error-message.' + ${errorMessage}}"/>
<a th:href="@{/accountOverview}" th:text="#{financer.search-transactions.back-to-overview}"/>
<form id="search-transactions-form" action="#" th:action="@{/searchTransactions}" th:object="${form}"
method="post">
<label for="inputFql" th:text="#{financer.search-transactions.label.fql}"/>
<input type="text" id="inputFql" th:field="*{fql}"/>
<input type="submit" th:value="#{financer.search-transactions.submit}"/>
</form>
<details>
<summary th:text="#{financer.search-transactions.show-query-options}"/>
<div id="search-transactions-fql-detail">
<p>
<div>Available fields:</div>
<ul>
<li>amount: the amount of a transaction</li>
<li>fromAccount: the key of the from account</li>
<li>toAccount: the key of the to account</li>
<li>fromAccountGroup: the name of the account group of the from account</li>
<li>toAccountGroup: the name of the account group of the to account</li>
<li>date: the date of the transaction in the format yyyy-mm-dd</li>
<li>recurring: whether the transaction has been created from a recurring transaction</li>
<li>taxRelevant: whether the transaction is relevant for tax declaration</li>
<li>period: the period the transaction is assigned to
<ul>
<li>CURRENT: denotes the current expense period</li>
<li>LAST: denotes the last expense period</li>
<li>CURRENT_YEAR: denotes the current year period</li>
<li>LAST_YEAR: denotes the last year period</li>
<li>GRAND_TOTAL: denotes the grand total period</li>
</ul>
</li>
</ul>
</p>
<p>
<div>General</div>
<ul>
<li>Conjunctions with OR, AND</li>
<li>Grouping of expressions with parenthesis</li>
<li>Strings enclosed in single quotes</li>
<li>Case-insensitive</li>
<li>Possible operators =, >, >=, <, <=, !=</li>
<li>Ordering for a single field ASC, DESC via ORDER BY</li>
</ul>
</p>
</div>
</details>
<div th:replace="transaction/transactionList :: transaction-list"/>
<div th:replace="includes/footer :: footer"/>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<div id="transaction-list-container" th:fragment="transaction-list">
<table id="transaction-table">
<tr>
<th class="hideable-column" th:text="#{financer.transaction-list.table-header.id}"/>
<th th:text="#{financer.transaction-list.table-header.fromAccount}"/>
<th th:text="#{financer.transaction-list.table-header.toAccount}"/>
<th th:text="#{financer.transaction-list.table-header.date}"/>
<th th:text="#{financer.transaction-list.table-header.amount}"/>
<th th:text="#{financer.transaction-list.table-header.description}"/>
<th th:text="#{financer.transaction-list.table-header.byRecurring}"/>
<th th:text="#{financer.transaction-list.table-header.taxRelevant}"/>
<th th:if="${showActions}" th:text="#{financer.transaction-list.table-header.actions}"/>
</tr>
<tr th:each="transaction : ${transactions}">
<td class="hideable-column" th:text="${transaction.id}"/>
<td th:text="${transaction.fromAccount.key}" />
<td th:text="${transaction.toAccount.key}" />
<td th:text="${#temporals.format(transaction.date)}" />
<td th:text="${#numbers.formatDecimal(transaction.amount/100D, 1, 'DEFAULT', 2, 'DEFAULT') + currencySymbol}"/>
<td th:text="${transaction.description}" />
<td th:if="${transaction.recurring}" th:text="#{financer.transaction-list.table.recurring.yes}" />
<td th:if="${!transaction.recurring}" th:text="#{financer.transaction-list.table.recurring.no}" />
<td th:text="#{'financer.transaction-list.table.taxRelevant.' + ${transaction.taxRelevant}}" />
<td th:if="${showActions}">
<div id="account-transaction-table-actions-container">
<a th:href="@{/deleteTransaction(transactionId=${transaction.id}, accountKey=${account.key})}"
th:text="#{financer.transaction-list.table.actions.deleteTransaction}"/>
</div>
</td>
</tr>
</table>
</div>