From d8d75f67b421cb8abc9ab1e85f36afda750f6053 Mon Sep 17 00:00:00 2001 From: MK13 Date: Fri, 10 Apr 2020 02:20:34 +0200 Subject: [PATCH] Add file uploading to transaction creation --- .../main/java/de/financer/ResponseReason.java | 6 ++- .../dto/SaveTransactionRequestDto.java | 18 ++++++++ .../dto/SearchTransactionsRequestDto.java | 9 ++++ .../dto/SearchTransactionsResponseDto.java | 13 +++++- .../src/main/java/de/financer/model/File.java | 33 ++++++++++++++ .../java/de/financer/model/Transaction.java | 15 +++++++ .../financer/controller/FileController.java | 33 ++++++++++++++ .../controller/TransactionController.java | 12 ++++-- .../java/de/financer/dba/FileRepository.java | 7 +++ .../dba/TransactionRepositoryCustom.java | 3 +- .../impl/TransactionRepositoryCustomImpl.java | 17 +++++++- .../java/de/financer/fql/FQLVisitorImpl.java | 6 ++- .../java/de/financer/fql/FieldMapping.java | 4 +- .../fql/join_handler/FileJoinHandler.java | 28 ++++++++++++ .../java/de/financer/service/FileService.java | 28 ++++++++++++ .../service/RecurringTransactionService.java | 4 +- .../financer/service/TransactionService.java | 40 ++++++++++++++--- .../SearchTransactionsParameter.java | 9 ++++ .../database/hsqldb/V28_0_1__file.sql | 14 ++++++ .../database/postgres/V28_0_1__file.sql | 14 ++++++ ...nsactionService_createTransactionTest.java | 18 ++++---- .../controller/AccountController.java | 2 +- .../financer/controller/FileController.java | 43 +++++++++++++++++++ .../java/de/financer/controller/Function.java | 4 +- .../controller/TransactionController.java | 15 ++++++- .../de/financer/form/NewTransactionForm.java | 11 +++++ .../template/FinancerRestTemplate.java | 13 ++++++ .../main/resources/i18n/message.properties | 8 +++- .../resources/i18n/message_de_DE.properties | 8 +++- .../src/main/resources/static/changelog.txt | 4 +- .../templates/transaction/newTransaction.html | 8 ++-- .../transaction/searchTransactions.html | 1 + .../transaction/transactionList.html | 8 +++- 33 files changed, 417 insertions(+), 39 deletions(-) create mode 100644 financer-common/src/main/java/de/financer/model/File.java create mode 100644 financer-server/src/main/java/de/financer/controller/FileController.java create mode 100644 financer-server/src/main/java/de/financer/dba/FileRepository.java create mode 100644 financer-server/src/main/java/de/financer/fql/join_handler/FileJoinHandler.java create mode 100644 financer-server/src/main/java/de/financer/service/FileService.java create mode 100644 financer-server/src/main/resources/database/hsqldb/V28_0_1__file.sql create mode 100644 financer-server/src/main/resources/database/postgres/V28_0_1__file.sql create mode 100644 financer-web-client/src/main/java/de/financer/controller/FileController.java diff --git a/financer-common/src/main/java/de/financer/ResponseReason.java b/financer-common/src/main/java/de/financer/ResponseReason.java index 9241edd..1a8d373 100644 --- a/financer-common/src/main/java/de/financer/ResponseReason.java +++ b/financer-common/src/main/java/de/financer/ResponseReason.java @@ -38,7 +38,11 @@ public enum ResponseReason { 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); + FQL_MALFORMED(HttpStatus.BAD_REQUEST), + INVALID_FILE_CONTENT(HttpStatus.BAD_REQUEST), + INVALID_FILE_NAME(HttpStatus.BAD_REQUEST), + INVALID_HAS_FILE_VALUE(HttpStatus.BAD_REQUEST), + FILE_NOT_FOUND(HttpStatus.BAD_REQUEST); private final HttpStatus httpStatus; diff --git a/financer-common/src/main/java/de/financer/dto/SaveTransactionRequestDto.java b/financer-common/src/main/java/de/financer/dto/SaveTransactionRequestDto.java index 3615afa..e162b0b 100644 --- a/financer-common/src/main/java/de/financer/dto/SaveTransactionRequestDto.java +++ b/financer-common/src/main/java/de/financer/dto/SaveTransactionRequestDto.java @@ -9,6 +9,8 @@ public class SaveTransactionRequestDto { private String date; private String description; private Boolean taxRelevant; + private String fileContent; + private String fileName; public String getFromAccountKey() { return fromAccountKey; @@ -62,4 +64,20 @@ public class SaveTransactionRequestDto { public String toString() { return ReflectionToStringBuilder.toString(this); } + + public String getFileContent() { + return fileContent; + } + + public void setFileContent(String fileContent) { + this.fileContent = fileContent; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } } diff --git a/financer-common/src/main/java/de/financer/dto/SearchTransactionsRequestDto.java b/financer-common/src/main/java/de/financer/dto/SearchTransactionsRequestDto.java index c7a0ff5..e387e95 100644 --- a/financer-common/src/main/java/de/financer/dto/SearchTransactionsRequestDto.java +++ b/financer-common/src/main/java/de/financer/dto/SearchTransactionsRequestDto.java @@ -9,6 +9,7 @@ public class SearchTransactionsRequestDto { private String limit; private String order; private String taxRelevant; + private String hasFile; public String getFromAccountKey() { return fromAccountKey; @@ -62,4 +63,12 @@ public class SearchTransactionsRequestDto { public String toString() { return ReflectionToStringBuilder.toString(this); } + + public String getHasFile() { + return hasFile; + } + + public void setHasFile(String hasFile) { + this.hasFile = hasFile; + } } diff --git a/financer-common/src/main/java/de/financer/dto/SearchTransactionsResponseDto.java b/financer-common/src/main/java/de/financer/dto/SearchTransactionsResponseDto.java index e6f83a4..5343f63 100644 --- a/financer-common/src/main/java/de/financer/dto/SearchTransactionsResponseDto.java +++ b/financer-common/src/main/java/de/financer/dto/SearchTransactionsResponseDto.java @@ -13,6 +13,7 @@ public class SearchTransactionsResponseDto { private long amount; private boolean taxRelevant; private boolean recurring; + private Long fileId; public SearchTransactionsResponseDto() { // No-arg constructor for Jackson @@ -25,7 +26,8 @@ public class SearchTransactionsResponseDto { String description, long amount, boolean taxRelevant, - boolean recurring) { + boolean recurring, + Long fileId) { this.id = id; this.fromAccount = fromAccount; this.toAccount = toAccount; @@ -34,6 +36,7 @@ public class SearchTransactionsResponseDto { this.amount = amount; this.taxRelevant = taxRelevant; this.recurring = recurring; + this.fileId = fileId; } public Long getId() { @@ -99,4 +102,12 @@ public class SearchTransactionsResponseDto { public void setRecurring(boolean recurring) { this.recurring = recurring; } + + public Long getFileId() { + return fileId; + } + + public void setFileId(Long fileId) { + this.fileId = fileId; + } } diff --git a/financer-common/src/main/java/de/financer/model/File.java b/financer-common/src/main/java/de/financer/model/File.java new file mode 100644 index 0000000..5c01481 --- /dev/null +++ b/financer-common/src/main/java/de/financer/model/File.java @@ -0,0 +1,33 @@ +package de.financer.model; + +import javax.persistence.*; + +@Entity +@Table(name = "file") +public class File { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + private String content; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/financer-common/src/main/java/de/financer/model/Transaction.java b/financer-common/src/main/java/de/financer/model/Transaction.java index 7e26e1d..c57e2b4 100644 --- a/financer-common/src/main/java/de/financer/model/Transaction.java +++ b/financer-common/src/main/java/de/financer/model/Transaction.java @@ -28,6 +28,13 @@ public class Transaction { //@formatter:on private Set periods; private boolean taxRelevant; + @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, fetch = FetchType.EAGER) + //@formatter:off + @JoinTable(name = "link_transaction_file", + joinColumns = @JoinColumn(name = "transaction_id"), + inverseJoinColumns = @JoinColumn(name = "file_id")) + //@formatter:on + private Set files; public Long getId() { return id; @@ -96,4 +103,12 @@ public class Transaction { public void setTaxRelevant(boolean taxRelevant) { this.taxRelevant = taxRelevant; } + + public Set getFiles() { + return files; + } + + public void setFiles(Set files) { + this.files = files; + } } diff --git a/financer-server/src/main/java/de/financer/controller/FileController.java b/financer-server/src/main/java/de/financer/controller/FileController.java new file mode 100644 index 0000000..72f610a --- /dev/null +++ b/financer-server/src/main/java/de/financer/controller/FileController.java @@ -0,0 +1,33 @@ +package de.financer.controller; + +import de.financer.model.File; +import de.financer.service.FileService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +public class FileController { + private static final Logger LOGGER = LoggerFactory.getLogger(TransactionController.class); + + @Autowired + private FileService fileService; + + @GetMapping("/file") + public File getFile(Long fileId) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("GET /file got parameter: %s", fileId)); + } + + final File file = this.fileService.getFile(fileId); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("GET /file returns with %s", file.getName())); + } + + return file; + } +} diff --git a/financer-server/src/main/java/de/financer/controller/TransactionController.java b/financer-server/src/main/java/de/financer/controller/TransactionController.java index b6b580d..d72bd57 100644 --- a/financer-server/src/main/java/de/financer/controller/TransactionController.java +++ b/financer-server/src/main/java/de/financer/controller/TransactionController.java @@ -46,13 +46,14 @@ public class TransactionController { String limit, String order, String taxRelevant, - String accountsAnd) { + String accountsAnd, + String hasFile) { 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)); + LOGGER.debug(String.format("GET /transactions/ got parameter: %s, %s, %s, %s, %s, %s, %s - with order %s", + fromDecoded, toDecoded, periodId, limit, taxRelevant, accountsAnd, hasFile, order)); } // Wrap the url parameters into a proper POJO so the service parameter list wont get polluted @@ -66,6 +67,7 @@ public class TransactionController { parameter.setOrder(order); parameter.setTaxRelevant(taxRelevant); parameter.setAccountsAnd(accountsAnd); + parameter.setHasFile(hasFile); final Iterable transactionSearchResponse = this.transactionService.searchTransactions(parameter); @@ -92,7 +94,9 @@ public class TransactionController { Long.valueOf(requestDto.getAmount()), requestDto.getDate(), requestDto.getDescription(), - requestDto.getTaxRelevant()); + requestDto.getTaxRelevant(), + requestDto.getFileName(), + requestDto.getFileContent()); if (LOGGER.isDebugEnabled()) { LOGGER.debug(String.format("POST /transactions returns with %s", responseReason.name())); diff --git a/financer-server/src/main/java/de/financer/dba/FileRepository.java b/financer-server/src/main/java/de/financer/dba/FileRepository.java new file mode 100644 index 0000000..2fd220a --- /dev/null +++ b/financer-server/src/main/java/de/financer/dba/FileRepository.java @@ -0,0 +1,7 @@ +package de.financer.dba; + +import de.financer.model.File; +import org.springframework.data.repository.CrudRepository; + +public interface FileRepository extends CrudRepository { +} diff --git a/financer-server/src/main/java/de/financer/dba/TransactionRepositoryCustom.java b/financer-server/src/main/java/de/financer/dba/TransactionRepositoryCustom.java index cd9461f..6e98e89 100644 --- a/financer-server/src/main/java/de/financer/dba/TransactionRepositoryCustom.java +++ b/financer-server/src/main/java/de/financer/dba/TransactionRepositoryCustom.java @@ -12,7 +12,8 @@ public interface TransactionRepositoryCustom { Integer limit, Order order, Boolean taxRelevant, - boolean accountsAnd); + boolean accountsAnd, + Boolean hasFile); Iterable searchTransactions(String fql); } diff --git a/financer-server/src/main/java/de/financer/dba/impl/TransactionRepositoryCustomImpl.java b/financer-server/src/main/java/de/financer/dba/impl/TransactionRepositoryCustomImpl.java index fb32d1a..af1080f 100644 --- a/financer-server/src/main/java/de/financer/dba/impl/TransactionRepositoryCustomImpl.java +++ b/financer-server/src/main/java/de/financer/dba/impl/TransactionRepositoryCustomImpl.java @@ -30,11 +30,13 @@ public class TransactionRepositoryCustomImpl implements TransactionRepositoryCus Integer limit, Order order, Boolean taxRelevant, - boolean accountsAnd) { + boolean accountsAnd, + Boolean hasFile) { final CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder(); final CriteriaQuery criteriaQuery = criteriaBuilder .createQuery(SearchTransactionsResponseDto.class); final Root fromTransaction = criteriaQuery.from(Transaction.class); + final SetJoin fileJoin = fromTransaction.join(Transaction_.files, JoinType.LEFT); final List predicates = new ArrayList<>(); Predicate fromAccountPredicate = null; @@ -74,6 +76,15 @@ public class TransactionRepositoryCustomImpl implements TransactionRepositoryCus } // else: both null, nothing to do + if (hasFile != null) { + if (hasFile) { + predicates.add(criteriaBuilder.isNotNull(fileJoin.get(File_.id))); + } + else { + predicates.add(criteriaBuilder.isNull(fileJoin.get(File_.id))); + } + } + criteriaQuery.where(predicates.toArray(new Predicate[]{})); switch(order) { @@ -92,7 +103,9 @@ public class TransactionRepositoryCustomImpl implements TransactionRepositoryCus fromTransaction.get(Transaction_.description), fromTransaction.get(Transaction_.amount), fromTransaction.get(Transaction_.taxRelevant), - criteriaBuilder.isNotNull(fromTransaction.get(Transaction_.recurringTransaction)))); + criteriaBuilder.isNotNull(fromTransaction.get(Transaction_.recurringTransaction)), + // This is kinda hacky as the relation between transaction and file is one-to-many + fileJoin.get(File_.id))); final TypedQuery query = this.entityManager.createQuery(criteriaQuery); diff --git a/financer-server/src/main/java/de/financer/fql/FQLVisitorImpl.java b/financer-server/src/main/java/de/financer/fql/FQLVisitorImpl.java index 74ccc44..b8129d2 100644 --- a/financer-server/src/main/java/de/financer/fql/FQLVisitorImpl.java +++ b/financer-server/src/main/java/de/financer/fql/FQLVisitorImpl.java @@ -27,6 +27,9 @@ public class FQLVisitorImpl extends FQLBaseVisitor { this.froms = new HashMap<>(); this.froms.put(JoinKey.of(Transaction.class), this.criteriaRoot); + + // We always need to join the join for HAS_FILE because it required for the select clause + FieldMapping.HAS_FILE.getJoinHandler().apply(this.froms, FieldMapping.HAS_FILE); } public CriteriaQuery getCriteriaQuery() { @@ -58,7 +61,8 @@ public class FQLVisitorImpl extends FQLBaseVisitor { this.criteriaRoot.get(Transaction_.description), this.criteriaRoot.get(Transaction_.amount), this.criteriaRoot.get(Transaction_.taxRelevant), - criteriaBuilder.isNotNull(this.criteriaRoot.get(Transaction_.recurringTransaction)))); + this.criteriaBuilder.isNotNull(this.criteriaRoot.get(Transaction_.recurringTransaction)), + this.froms.get(FieldMapping.HAS_FILE.getJoinKey()).get(FieldMapping.HAS_FILE.getAttributeName()))); } @Override diff --git a/financer-server/src/main/java/de/financer/fql/FieldMapping.java b/financer-server/src/main/java/de/financer/fql/FieldMapping.java index 4ae4d32..c2dd772 100644 --- a/financer-server/src/main/java/de/financer/fql/FieldMapping.java +++ b/financer-server/src/main/java/de/financer/fql/FieldMapping.java @@ -30,7 +30,9 @@ public enum FieldMapping { JoinKey.of(Transaction.class), null, NotNullSyntheticHandler.class), TAX_RELEVANT("taxRelevant", Transaction_.TAX_RELEVANT, Transaction.class, NoopJoinHandler.class, - JoinKey.of(Transaction.class), null, BooleanHandler.class); + JoinKey.of(Transaction.class), null, BooleanHandler.class), + + HAS_FILE("hasFile", File_.ID, File.class, FileJoinHandler.class, JoinKey.of(File.class), null, NotNullSyntheticHandler.class); /** * The name of the field as used in FQL diff --git a/financer-server/src/main/java/de/financer/fql/join_handler/FileJoinHandler.java b/financer-server/src/main/java/de/financer/fql/join_handler/FileJoinHandler.java new file mode 100644 index 0000000..99831a5 --- /dev/null +++ b/financer-server/src/main/java/de/financer/fql/join_handler/FileJoinHandler.java @@ -0,0 +1,28 @@ +package de.financer.fql.join_handler; + +import de.financer.fql.FieldMapping; +import de.financer.model.File; +import de.financer.model.Transaction; +import de.financer.model.Transaction_; + +import javax.persistence.criteria.From; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.SetJoin; +import java.util.Map; + +public class FileJoinHandler implements JoinHandler { + + @Override + public Void apply(Map> joinKeyFromMap, FieldMapping fieldMapping) { + if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) { + final SetJoin periodJoin = ((Root) joinKeyFromMap + .get(JoinKey.of(Transaction.class))) + .join(Transaction_.files, JoinType.LEFT); + + joinKeyFromMap.put(fieldMapping.getJoinKey(), periodJoin); + } + + return null; + } +} diff --git a/financer-server/src/main/java/de/financer/service/FileService.java b/financer-server/src/main/java/de/financer/service/FileService.java new file mode 100644 index 0000000..67cf72f --- /dev/null +++ b/financer-server/src/main/java/de/financer/service/FileService.java @@ -0,0 +1,28 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.FileRepository; +import de.financer.model.File; +import de.financer.service.exception.FinancerServiceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class FileService { + private static final Logger LOGGER = LoggerFactory.getLogger(TransactionService.class); + + @Autowired + private FileRepository fileRepository; + + public File getFile(Long fileId) { + if (fileId == null || !this.fileRepository.existsById(fileId)) { + LOGGER.error(String.format("File with ID %s not found!", fileId)); + + throw new FinancerServiceException(ResponseReason.FILE_NOT_FOUND); + } + + return this.fileRepository.findById(fileId).get(); + } +} diff --git a/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java index a267666..a84858f 100644 --- a/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java +++ b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java @@ -476,7 +476,9 @@ public class RecurringTransactionService { LocalDate.now().format(DateTimeFormatter.ofPattern(this.financerConfig.getDateFormat())), recurringTransaction.getDescription(), recurringTransaction, - recurringTransaction.isTaxRelevant()); + recurringTransaction.isTaxRelevant(), + null, + null); } @Transactional(propagation = Propagation.REQUIRED) diff --git a/financer-server/src/main/java/de/financer/service/TransactionService.java b/financer-server/src/main/java/de/financer/service/TransactionService.java index c49e976..ffb173b 100644 --- a/financer-server/src/main/java/de/financer/service/TransactionService.java +++ b/financer-server/src/main/java/de/financer/service/TransactionService.java @@ -55,19 +55,20 @@ public class TransactionService { @Transactional(propagation = Propagation.REQUIRED) public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date, - String description, Boolean taxRelevant + String description, Boolean taxRelevant, String fileName, String fileContent ) { - return this.createTransaction(fromAccountKey, toAccountKey, amount, date, description, null, taxRelevant); + return this.createTransaction(fromAccountKey, toAccountKey, amount, date, description, null, + taxRelevant, fileName, fileContent); } @Transactional(propagation = Propagation.REQUIRED) public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date, String description, RecurringTransaction recurringTransaction, - Boolean taxRelevant + Boolean taxRelevant, String fileName, String fileContent ) { final Account fromAccount = this.accountService.getAccountByKey(fromAccountKey); final Account toAccount = this.accountService.getAccountByKey(toAccountKey); - ResponseReason response = validateParameters(fromAccount, toAccount, amount, date); + ResponseReason response = validateParameters(fromAccount, toAccount, amount, date, fileName, fileContent); // If we detected an issue with the given parameters return the first error found to the caller if (response != null) { @@ -79,6 +80,7 @@ public class TransactionService { buildTransaction(fromAccount, toAccount, amount, description, date, recurringTransaction, taxRelevant); transaction.setPeriods(getRelevantPeriods(transaction)); + transaction.setFiles(Collections.singleton(buildFile(fileName, fileContent))); fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService .getMultiplierFromAccount(fromAccount) * amount)); @@ -110,6 +112,15 @@ public class TransactionService { return response; } + private File buildFile(String fileName, String fileContent) { + final File file = new File(); + + file.setName(fileName); + file.setContent(fileContent); + + return file; + } + private Set getRelevantPeriods(Transaction transaction) { final Set periods = new HashSet<>(); final Period expensePeriod = this.periodService.getExpensePeriodForTransaction(transaction); @@ -158,10 +169,13 @@ public class TransactionService { * @param toAccount the to account * @param amount the transaction amount * @param date the transaction date + * @param fileName the name of the file + * @param fileContent the BASE64 encoded content of the file * * @return the first error found or null if all parameters are valid */ - private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, String date) { + private ResponseReason validateParameters(Account fromAccount, Account toAccount, Long amount, String date, + String fileName, String fileContent) { ResponseReason response = null; if (fromAccount == null && toAccount == null) { @@ -184,6 +198,11 @@ public class TransactionService { } catch (DateTimeParseException e) { response = ResponseReason.INVALID_DATE_FORMAT; } + // A file is only considered valid if both name and content are not empty + } else if (StringUtils.isNotEmpty(fileName) && StringUtils.isEmpty(fileContent)) { + response = ResponseReason.INVALID_FILE_CONTENT; + } else if (StringUtils.isEmpty(fileName) && StringUtils.isNotEmpty(fileContent)) { + response = ResponseReason.INVALID_FILE_NAME; } return response; @@ -298,6 +317,7 @@ public class TransactionService { Account toAccount = null; Boolean taxRelevant = null; Boolean accountsAnd = Boolean.FALSE; // account values are OR conjunct by default + Boolean hasFile = null; if (StringUtils.isNotEmpty(parameter.getPeriodId())) { if (!NumberUtils.isCreatable(parameter.getPeriodId())) { @@ -351,8 +371,16 @@ public class TransactionService { } } + if(StringUtils.isNotEmpty(parameter.getHasFile())) { + hasFile = BooleanUtils.toBooleanObject(parameter.getAccountsAnd()); + + if (hasFile == null) { + throw new FinancerServiceException(ResponseReason.INVALID_HAS_FILE_VALUE); + } + } + return this.transactionRepository.searchTransactions(period, fromAccount, toAccount, limit, order, taxRelevant, - accountsAnd); + accountsAnd, hasFile); } public Iterable searchTransactions(String fql) { diff --git a/financer-server/src/main/java/de/financer/service/parameter/SearchTransactionsParameter.java b/financer-server/src/main/java/de/financer/service/parameter/SearchTransactionsParameter.java index 4a79598..cb9ab1e 100644 --- a/financer-server/src/main/java/de/financer/service/parameter/SearchTransactionsParameter.java +++ b/financer-server/src/main/java/de/financer/service/parameter/SearchTransactionsParameter.java @@ -8,6 +8,7 @@ public class SearchTransactionsParameter { private String order; private String taxRelevant; private String accountsAnd; + private String hasFile; public String getFromAccountKey() { return fromAccountKey; @@ -64,4 +65,12 @@ public class SearchTransactionsParameter { public void setAccountsAnd(String accountsAnd) { this.accountsAnd = accountsAnd; } + + public String getHasFile() { + return hasFile; + } + + public void setHasFile(String hasFile) { + this.hasFile = hasFile; + } } diff --git a/financer-server/src/main/resources/database/hsqldb/V28_0_1__file.sql b/financer-server/src/main/resources/database/hsqldb/V28_0_1__file.sql new file mode 100644 index 0000000..9b08324 --- /dev/null +++ b/financer-server/src/main/resources/database/hsqldb/V28_0_1__file.sql @@ -0,0 +1,14 @@ +CREATE TABLE file ( + id BIGINT NOT NULL PRIMARY KEY IDENTITY, + name NVARCHAR(1000) NOT NULL, + content NVARCHAR(4000) NOT NULL +); + +CREATE TABLE link_transaction_file ( + id BIGINT NOT NULL PRIMARY KEY IDENTITY, + transaction_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + + CONSTRAINT fk_link_transaction_file_transaction FOREIGN KEY (transaction_id) REFERENCES "transaction" (id), + CONSTRAINT fk_link_transaction_file_file FOREIGN KEY (file_id) REFERENCES file (id) +); \ No newline at end of file diff --git a/financer-server/src/main/resources/database/postgres/V28_0_1__file.sql b/financer-server/src/main/resources/database/postgres/V28_0_1__file.sql new file mode 100644 index 0000000..95987e5 --- /dev/null +++ b/financer-server/src/main/resources/database/postgres/V28_0_1__file.sql @@ -0,0 +1,14 @@ +CREATE TABLE file ( + id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name VARCHAR(1000) NOT NULL, + content VARCHAR NOT NULL +); + +CREATE TABLE link_transaction_file ( + id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + transaction_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + + CONSTRAINT fk_link_transaction_file_transaction FOREIGN KEY (transaction_id) REFERENCES "transaction" (id), + CONSTRAINT fk_link_transaction_file_file FOREIGN KEY (file_id) REFERENCES file (id) +); \ No newline at end of file diff --git a/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java b/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java index a9eae84..5f72bd8 100644 --- a/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java +++ b/financer-server/src/test/java/de/financer/service/TransactionService_createTransactionTest.java @@ -48,7 +48,7 @@ public class TransactionService_createTransactionTest { // will not be found. // Act - final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", 150L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.invalid", 150L, "24.02.2019", "XXX", false, null, null); // 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", 150L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.invalid", 150L, "24.02.2019", "XXX", false, null, null); // 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", 150L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.invalid", "account.to", 150L, "24.02.2019", "XXX", false, null, null); // 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", 150L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 150L, "24.02.2019", "XXX", false, null, null); // Assert Assert.assertEquals(ResponseReason.INVALID_BOOKING_ACCOUNTS, response); @@ -98,7 +98,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", null, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", null, "24.02.2019", "XXX", false, null, null); // Assert Assert.assertEquals(ResponseReason.MISSING_AMOUNT, 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", 0L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 0L, "24.02.2019", "XXX", false, null, null); // 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", 125L, null, "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, null, "XXX", false, null, null); // 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", 125L, "2019-01-01", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, "2019-01-01", "XXX", false, null, null); // Assert Assert.assertEquals(ResponseReason.INVALID_DATE_FORMAT, response); @@ -156,7 +156,7 @@ public class TransactionService_createTransactionTest { Mockito.when(toAccount.getCurrentBalance()).thenReturn(0L); // Act - final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, "24.02.2019", "XXX", false); + final ResponseReason response = this.classUnderTest.createTransaction("account.from", "account.to", 125L, "24.02.2019", "XXX", false, null, null); // Assert Assert.assertEquals(ResponseReason.CREATED, response); diff --git a/financer-web-client/src/main/java/de/financer/controller/AccountController.java b/financer-web-client/src/main/java/de/financer/controller/AccountController.java index 4341eff..627b067 100644 --- a/financer-web-client/src/main/java/de/financer/controller/AccountController.java +++ b/financer-web-client/src/main/java/de/financer/controller/AccountController.java @@ -126,7 +126,7 @@ public class AccountController { model.addAttribute("account", account); model.addAttribute("transactions", transactions); - model.addAttribute("showActions", true); + model.addAttribute("showActionDelete", true); model.addAttribute("isClosed", AccountStatus.CLOSED.equals(account.getStatus())); } catch(FinancerRestException e) { diff --git a/financer-web-client/src/main/java/de/financer/controller/FileController.java b/financer-web-client/src/main/java/de/financer/controller/FileController.java new file mode 100644 index 0000000..5d26b04 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/controller/FileController.java @@ -0,0 +1,43 @@ +package de.financer.controller; + +import de.financer.config.FinancerConfig; +import de.financer.model.File; +import de.financer.template.FinancerRestTemplate; +import de.financer.template.exception.FinancerRestException; +import de.financer.util.ControllerUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Base64; + +@Controller +public class FileController { + @Autowired + private FinancerConfig financerConfig; + + @GetMapping("/downloadFile") + public ResponseEntity downloadFile(Long fileId) { + final UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(ControllerUtils.buildUrl(this.financerConfig, Function.FILE_GET)) + .queryParam("fileId", fileId); + + final File file; + + try { + file = FinancerRestTemplate.exchangeGet(builder, File.class); + } catch (FinancerRestException e) { + // TODO + return null; + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"") + .body(new ByteArrayResource(Base64.getDecoder().decode(file.getContent()))); + } +} diff --git a/financer-web-client/src/main/java/de/financer/controller/Function.java b/financer-web-client/src/main/java/de/financer/controller/Function.java index 2f4d7e9..ebae840 100644 --- a/financer-web-client/src/main/java/de/financer/controller/Function.java +++ b/financer-web-client/src/main/java/de/financer/controller/Function.java @@ -32,7 +32,9 @@ public enum Function { RT_CREATE_TRANSACTION("recurringTransactions/createTransaction"), P_GET_CURRENT_EXPENSE_PERIOD("periods/getCurrentExpensePeriod"), - P_CLOSE_CURRENT_EXPENSE_PERIOD("periods/closeCurrentExpensePeriod"); + P_CLOSE_CURRENT_EXPENSE_PERIOD("periods/closeCurrentExpensePeriod"), + + FILE_GET("file"); private final String path; diff --git a/financer-web-client/src/main/java/de/financer/controller/TransactionController.java b/financer-web-client/src/main/java/de/financer/controller/TransactionController.java index 1ca6444..9c76853 100644 --- a/financer-web-client/src/main/java/de/financer/controller/TransactionController.java +++ b/financer-web-client/src/main/java/de/financer/controller/TransactionController.java @@ -19,6 +19,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.util.UriComponentsBuilder; +import java.io.IOException; +import java.util.Base64; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -32,7 +34,7 @@ public class TransactionController { @GetMapping("/searchTransactions") public String searchTransaction(Model model) { model.addAttribute("form", new SearchTransactionsForm()); - model.addAttribute("showActions", false); + model.addAttribute("showActionDelete", false); ControllerUtils.addVersionAttribute(model, this.financerConfig); ControllerUtils.addCurrencySymbol(model, this.financerConfig); @@ -60,7 +62,7 @@ public class TransactionController { } model.addAttribute("form", form); - model.addAttribute("showActions", false); + model.addAttribute("showActionDelete", false); ControllerUtils.addVersionAttribute(model, this.financerConfig); ControllerUtils.addCurrencySymbol(model, this.financerConfig); @@ -117,6 +119,15 @@ public class TransactionController { requestDto.setDescription(form.getDescription()); requestDto.setTaxRelevant(form.getTaxRelevant()); + if (form.getFile() != null) { + try { + requestDto.setFileContent(Base64.getEncoder().encodeToString(form.getFile().getBytes())); + requestDto.setFileName(form.getFile().getOriginalFilename()); + } catch (IOException ioe) { + // TODO No file for us :( + } + } + final ResponseReason responseReason = FinancerRestTemplate .exchangePost(this.financerConfig, Function.TR_CREATE_TRANSACTION, requestDto); diff --git a/financer-web-client/src/main/java/de/financer/form/NewTransactionForm.java b/financer-web-client/src/main/java/de/financer/form/NewTransactionForm.java index f67f1b6..fd363e4 100644 --- a/financer-web-client/src/main/java/de/financer/form/NewTransactionForm.java +++ b/financer-web-client/src/main/java/de/financer/form/NewTransactionForm.java @@ -1,5 +1,7 @@ package de.financer.form; +import org.springframework.web.multipart.MultipartFile; + public class NewTransactionForm { private String fromAccountKey; private String toAccountKey; @@ -7,6 +9,7 @@ public class NewTransactionForm { private String date; private String description; private Boolean taxRelevant; + private MultipartFile file; public String getFromAccountKey() { return fromAccountKey; @@ -55,4 +58,12 @@ public class NewTransactionForm { public void setTaxRelevant(Boolean taxRelevant) { this.taxRelevant = taxRelevant; } + + public MultipartFile getFile() { + return file; + } + + public void setFile(MultipartFile file) { + this.file = file; + } } diff --git a/financer-web-client/src/main/java/de/financer/template/FinancerRestTemplate.java b/financer-web-client/src/main/java/de/financer/template/FinancerRestTemplate.java index ba6deb8..959e2a8 100644 --- a/financer-web-client/src/main/java/de/financer/template/FinancerRestTemplate.java +++ b/financer-web-client/src/main/java/de/financer/template/FinancerRestTemplate.java @@ -76,6 +76,19 @@ public class FinancerRestTemplate { return ResponseReason.fromResponseEntity(response); } + public static R exchangeGet(UriComponentsBuilder builder, Class type) throws FinancerRestException { + final RestTemplate restTemplate = new RestTemplate(); + + try { + return restTemplate.exchange(builder.toUriString(), HttpMethod.GET, null, type).getBody(); + } catch (HttpStatusCodeException e) { + final ResponseEntity exceptionResponse = new ResponseEntity<>(e.getResponseBodyAsString(), e + .getStatusCode()); + + throw new FinancerRestException(ResponseReason.fromResponseEntity(exceptionResponse)); + } + } + public static R exchangeGet(FinancerConfig financerConfig, Function endpoint, Class type) throws FinancerRestException { diff --git a/financer-web-client/src/main/resources/i18n/message.properties b/financer-web-client/src/main/resources/i18n/message.properties index 55a3aab..1ac87b1 100644 --- a/financer-web-client/src/main/resources/i18n/message.properties +++ b/financer-web-client/src/main/resources/i18n/message.properties @@ -44,6 +44,7 @@ financer.transaction-new.label.amount=Amount\: financer.transaction-new.label.date=Date\: financer.transaction-new.label.description=Description\: financer.transaction-new.label.taxRelevant=Tax relevant\: +financer.transaction-new.label.file=File\: financer.transaction-new.submit=Create transaction financer.transaction-new.account-type.BANK={0}|Bank|{1}{2} financer.transaction-new.account-type.CASH={0}|Cash|{1}{2} @@ -112,6 +113,7 @@ financer.account-details.details.balance=Current balance\: financer.account-details.details.group=Group\: financer.transaction-list.table-header.actions=Actions financer.transaction-list.table.actions.deleteTransaction=Delete +financer.transaction-list.table.actions.downloadFile=Download file financer.transaction-list.table.recurring.yes=Yes financer.transaction-list.table.recurring.no=No financer.transaction-list.table.taxRelevant.true=Yes @@ -135,6 +137,7 @@ financer.search-transactions.show-query-options.toAccountGroup=toAccountGroup\: financer.search-transactions.show-query-options.date=date\: the date of the transaction in the format yyyy-mm-dd financer.search-transactions.show-query-options.recurring=recurring\: whether the transaction has been created from a recurring transaction financer.search-transactions.show-query-options.taxRelevant=taxRelevant\: whether the transaction is relevant for tax declaration +financer.search-transactions.show-query-options.hasFile=hasFile\: whether the transaction has a file linked financer.search-transactions.show-query-options.period=period\: the period the transaction is assigned to financer.search-transactions.show-query-options.period.CURRENT=CURRENT\: denotes the current expense period financer.search-transactions.show-query-options.period.LAST=LAST\: denotes the last expense period @@ -242,4 +245,7 @@ financer.error-message.ACCOUNT_NOT_FOUND=The account could not be found! financer.error-message.DUPLICATE_ACCOUNT_KEY=An account with the given key already exists! financer.error-message.DUPLICATE_ACCOUNT_GROUP_NAME=An account group with the given key already exists! financer.error-message.ACCOUNT_GROUP_NOT_FOUND=The account group could not be found! -financer.error-message.UNKNOWN_CHART_TYPE=The selected chart type is not known! \ No newline at end of file +financer.error-message.UNKNOWN_CHART_TYPE=The selected chart type is not known! +financer.error-message.INVALID_HAS_FILE_VALUE=The value for parameter hasFile cannot be parsed! +financer.error-message.INVALID_FILE_CONTENT=File content is missing! +financer.error-message.INVALID_FILE_NAME=File name is missing! \ No newline at end of file diff --git a/financer-web-client/src/main/resources/i18n/message_de_DE.properties b/financer-web-client/src/main/resources/i18n/message_de_DE.properties index b8af304..cfae917 100644 --- a/financer-web-client/src/main/resources/i18n/message_de_DE.properties +++ b/financer-web-client/src/main/resources/i18n/message_de_DE.properties @@ -44,6 +44,7 @@ financer.transaction-new.label.amount=Betrag\: financer.transaction-new.label.date=Datum\: financer.transaction-new.label.description=Beschreibung\: financer.transaction-new.label.taxRelevant=Relevant f\u00FCr Steuererkl\u00E4rung\: +financer.transaction-new.label.file=Datei\: financer.transaction-new.submit=Buchung erstellen financer.transaction-new.account-type.BANK={0}|Bank|{1}{2} financer.transaction-new.account-type.CASH={0}|Bar|{1}{2} @@ -112,6 +113,7 @@ financer.account-details.details.balance=Kontostand\: financer.account-details.details.group=Gruppe\: financer.transaction-list.table-header.actions=Aktionen financer.transaction-list.table.actions.deleteTransaction=L\u00F6schen +financer.transaction-list.table.actions.downloadFile=Datei herunterladen financer.transaction-list.table.recurring.yes=Ja financer.transaction-list.table.recurring.no=Nein financer.transaction-list.table.taxRelevant.true=Ja @@ -135,6 +137,7 @@ financer.search-transactions.show-query-options.toAccountGroup=toAccountGroup\: financer.search-transactions.show-query-options.date=date\: das Datum der Buchung im Format jjjj-mm-tt financer.search-transactions.show-query-options.recurring=recurring\: ob die Buchung durch eine wiederkehrende Buchung erzeugt wurde financer.search-transactions.show-query-options.taxRelevant=taxRelevant\: ob die Buchung als steuerrelevant markiert wurde +financer.search-transactions.show-query-options.hasFile=hasFile\: ob eine Datei der Buchung zugeordnet ist financer.search-transactions.show-query-options.period=period\: die Periode der Buchung financer.search-transactions.show-query-options.period.CURRENT=CURRENT\: bezeichnet die aktuelle Ausgabenperiode financer.search-transactions.show-query-options.period.LAST=LAST\: bezeichnet die letzte Ausgabenperiode @@ -241,4 +244,7 @@ financer.error-message.ACCOUNT_NOT_FOUND=Das ausgew\u00E4hlte Konto wurde nicht financer.error-message.DUPLICATE_ACCOUNT_KEY=Ein Konto mit diesem Schl\u00FCssel existiert bereits! financer.error-message.DUPLICATE_ACCOUNT_GROUP_NAME=Eine Konto-Gruppe mit diesem Namen existiert bereits! financer.error-message.ACCOUNT_GROUP_NOT_FOUND=Die ausgew\u00E4hlte Konto-Gruppe wurde nicht gefunden! -financer.error-message.UNKNOWN_CHART_TYPE=Das ausgew\u00E4hlte Diagramm wurde nicht gefunden! \ No newline at end of file +financer.error-message.UNKNOWN_CHART_TYPE=Das ausgew\u00E4hlte Diagramm wurde nicht gefunden! +financer.error-message.INVALID_HAS_FILE_VALUE=Der Wert des Parameters hasFile kann nicht verarbeitet werden! +financer.error-message.INVALID_FILE_CONTENT=Der Inhalt der Datei fehlt! +financer.error-message.INVALID_FILE_NAME=Der Dateiname fehlt! \ No newline at end of file diff --git a/financer-web-client/src/main/resources/static/changelog.txt b/financer-web-client/src/main/resources/static/changelog.txt index e5348f5..9313e48 100644 --- a/financer-web-client/src/main/resources/static/changelog.txt +++ b/financer-web-client/src/main/resources/static/changelog.txt @@ -1,5 +1,7 @@ v27 -> v28: - Add account end balance to account statistics for closed expense periods. This is currently not visible in the UI +- Add file upload to transaction creation for e.g. invoices. They can be downloaded in the transaction overview of an + account v26 -> v27: - Changed sort order of accounts in overview page. The accounts are now sorted by the account type first (BCILES), then @@ -7,7 +9,7 @@ v26 -> v27: - 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 - 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 +- Rework /transactions end point to better adhere to REST API requirements (proper HTTP return codes and HTTP method usage) v25 -> v26: diff --git a/financer-web-client/src/main/resources/templates/transaction/newTransaction.html b/financer-web-client/src/main/resources/templates/transaction/newTransaction.html index 49dfa63..e13c6c5 100644 --- a/financer-web-client/src/main/resources/templates/transaction/newTransaction.html +++ b/financer-web-client/src/main/resources/templates/transaction/newTransaction.html @@ -12,7 +12,7 @@
+ method="post" enctype="multipart/form-data">
+
\ No newline at end of file diff --git a/financer-web-client/src/main/resources/templates/transaction/searchTransactions.html b/financer-web-client/src/main/resources/templates/transaction/searchTransactions.html index 9db0f9f..2f03417 100644 --- a/financer-web-client/src/main/resources/templates/transaction/searchTransactions.html +++ b/financer-web-client/src/main/resources/templates/transaction/searchTransactions.html @@ -31,6 +31,7 @@
  • +