From b2d1b8572e463cf7ecb11abd91fbe75cc0ddd815 Mon Sep 17 00:00:00 2001 From: MK13 Date: Wed, 25 Aug 2021 21:41:00 +0200 Subject: [PATCH] #22 Add transaction upload --- .../CreateUploadedTransactionsRequestDto.java | 92 +++++++++++ .../financer/dto/TransactionUploadFormat.java | 5 + .../controller/TransactionController.java | 17 ++ .../service/RecurringTransactionService.java | 4 + .../financer/service/TransactionService.java | 54 ++++++ financer-web-client/pom.xml | 5 + .../de/financer/config/FinancerConfig.java | 46 +++++- .../de/financer/config/FormatConverter.java | 27 +++ .../TransactionUploadFormatConverter.java | 15 ++ .../java/de/financer/controller/Function.java | 1 + .../controller/TransactionController.java | 154 +++++++++++++++++- .../form/CreateUploadedTransactionForm.java | 123 ++++++++++++++ .../financer/form/UploadTransactionsForm.java | 24 +++ .../AbstractTransactionUploadWorker.java | 27 +++ .../MT940CSVTransactionUploadWorker.java | 99 +++++++++++ .../TransactionUploadWorker.java | 9 + .../TransactionUploadWorkerFactory.java | 27 +++ .../UploadedTransaction.java | 37 +++++ .../resources/config/application.properties | 21 ++- .../main/resources/i18n/message.properties | 27 +++ .../resources/i18n/message_de_DE.properties | 27 +++ .../src/main/resources/static/changelog.txt | 2 + .../src/main/resources/static/css/main.css | 21 ++- .../templates/account/accountOverview.html | 1 + .../createUploadedTransactions.html | 73 +++++++++ .../transaction/uploadTransactions.html | 29 ++++ 26 files changed, 957 insertions(+), 10 deletions(-) create mode 100644 financer-common/src/main/java/de/financer/dto/CreateUploadedTransactionsRequestDto.java create mode 100644 financer-common/src/main/java/de/financer/dto/TransactionUploadFormat.java create mode 100644 financer-web-client/src/main/java/de/financer/config/FormatConverter.java create mode 100644 financer-web-client/src/main/java/de/financer/config/TransactionUploadFormatConverter.java create mode 100644 financer-web-client/src/main/java/de/financer/form/CreateUploadedTransactionForm.java create mode 100644 financer-web-client/src/main/java/de/financer/form/UploadTransactionsForm.java create mode 100644 financer-web-client/src/main/java/de/financer/transactionUpload/AbstractTransactionUploadWorker.java create mode 100644 financer-web-client/src/main/java/de/financer/transactionUpload/MT940CSVTransactionUploadWorker.java create mode 100644 financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorker.java create mode 100644 financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorkerFactory.java create mode 100644 financer-web-client/src/main/java/de/financer/transactionUpload/UploadedTransaction.java create mode 100644 financer-web-client/src/main/resources/templates/transaction/createUploadedTransactions.html create mode 100644 financer-web-client/src/main/resources/templates/transaction/uploadTransactions.html diff --git a/financer-common/src/main/java/de/financer/dto/CreateUploadedTransactionsRequestDto.java b/financer-common/src/main/java/de/financer/dto/CreateUploadedTransactionsRequestDto.java new file mode 100644 index 0000000..a627b6e --- /dev/null +++ b/financer-common/src/main/java/de/financer/dto/CreateUploadedTransactionsRequestDto.java @@ -0,0 +1,92 @@ +package de.financer.dto; + +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; + +public class CreateUploadedTransactionsRequestDto { + private String fromAccountKey; + private String toAccountKey; + private String amount; + private String date; + private String description; + private Boolean taxRelevant; + private String fileContent; + private String fileName; + private String recurringTransactionId; + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this); + } + + 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; + } + + 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; + } + + public String getRecurringTransactionId() { + return recurringTransactionId; + } + + public void setRecurringTransactionId(String recurringTransactionId) { + this.recurringTransactionId = recurringTransactionId; + } +} diff --git a/financer-common/src/main/java/de/financer/dto/TransactionUploadFormat.java b/financer-common/src/main/java/de/financer/dto/TransactionUploadFormat.java new file mode 100644 index 0000000..8a52516 --- /dev/null +++ b/financer-common/src/main/java/de/financer/dto/TransactionUploadFormat.java @@ -0,0 +1,5 @@ +package de.financer.dto; + +public enum TransactionUploadFormat { + MT940_CSV; +} 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 d328e07..4c92f18 100644 --- a/financer-server/src/main/java/de/financer/controller/TransactionController.java +++ b/financer-server/src/main/java/de/financer/controller/TransactionController.java @@ -1,6 +1,7 @@ package de.financer.controller; import de.financer.ResponseReason; +import de.financer.dto.CreateUploadedTransactionsRequestDto; import de.financer.dto.ExpensePeriodTotal; import de.financer.dto.SaveTransactionRequestDto; import de.financer.dto.SearchTransactionsResponseDto; @@ -12,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Collection; import java.util.List; @RestController @@ -164,4 +166,19 @@ public class TransactionController { return response; } + + @PostMapping(value = "/transactions/upload") + public ResponseEntity createTransaction(@RequestBody Collection requestDtos) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("POST /transactions/upload got parameters: %s", requestDtos)); + } + + final ResponseReason responseReason = this.transactionService.createTransactions(requestDtos); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("POST /transactions/upload returns with %s", responseReason.name())); + } + + return responseReason.toResponseEntity(); + } } 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 8436202..f3db1f2 100644 --- a/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java +++ b/financer-server/src/main/java/de/financer/service/RecurringTransactionService.java @@ -60,6 +60,10 @@ public class RecurringTransactionService { return this.recurringTransactionRepository.findAllActive(LocalDate.now()); } + protected Optional findById(Long id) { + return this.recurringTransactionRepository.findById(id); + } + public Iterable getAllForAccount(String accountKey) { final Account account = this.accountService.getAccountByKey(accountKey); 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 1e8b1f6..31ef1f0 100644 --- a/financer-server/src/main/java/de/financer/service/TransactionService.java +++ b/financer-server/src/main/java/de/financer/service/TransactionService.java @@ -3,6 +3,7 @@ package de.financer.service; import de.financer.ResponseReason; import de.financer.config.FinancerConfig; import de.financer.dba.TransactionRepository; +import de.financer.dto.CreateUploadedTransactionsRequestDto; import de.financer.dto.ExpensePeriodTotal; import de.financer.dto.Order; import de.financer.dto.SearchTransactionsResponseDto; @@ -44,6 +45,9 @@ public class TransactionService { @Autowired private TransactionRepository transactionRepository; + @Autowired + private RecurringTransactionService recurringTransactionService; + @Autowired private FinancerConfig financerConfig; @@ -411,4 +415,54 @@ public class TransactionService { throw new FinancerServiceException(ResponseReason.FQL_MALFORMED); } } + + /** + * This method creates multiple transactions in one batch based on the given parameters. In case of invalid + * parameters the creation aborts and the first issue found is returned. + * + * @param requestDtos parameters to create the transactions for + * @return {@link ResponseReason#CREATED CREATED} in case of success, another instance of {@link ResponseReason} + * otherwise. Never null + */ + @Transactional(propagation = Propagation.REQUIRED) + public ResponseReason createTransactions(Collection requestDtos) { + requestDtos.stream().forEach(dto -> { + final ResponseReason responseReason; + + if(StringUtils.isNotEmpty(dto.getRecurringTransactionId())) { + final RecurringTransaction recurringTransaction = + this.recurringTransactionService.findById(Long.valueOf(dto.getRecurringTransactionId())) + .orElseThrow(() -> new FinancerServiceException( + ResponseReason.RECURRING_TRANSACTION_NOT_FOUND)); + + responseReason = this.createTransaction(dto.getFromAccountKey(), + dto.getToAccountKey(), + Long.valueOf(dto.getAmount()), + dto.getDate(), + dto.getDescription(), + recurringTransaction, + dto.getTaxRelevant(), + dto.getFileName(), + dto.getFileContent() + ); + } + else { + responseReason = this.createTransaction(dto.getFromAccountKey(), + dto.getToAccountKey(), + Long.valueOf(dto.getAmount()), + dto.getDate(), + dto.getDescription(), + dto.getTaxRelevant(), + dto.getFileName(), + dto.getFileContent() + ); + } + + if (responseReason != ResponseReason.CREATED) { + throw new FinancerServiceException(responseReason); + } + }); + + return ResponseReason.CREATED; + } } diff --git a/financer-web-client/pom.xml b/financer-web-client/pom.xml index 97146be..6e9933d 100644 --- a/financer-web-client/pom.xml +++ b/financer-web-client/pom.xml @@ -48,6 +48,11 @@ org.apache.commons commons-collections4 + + org.apache.commons + commons-csv + 1.9.0 + org.jfree diff --git a/financer-web-client/src/main/java/de/financer/config/FinancerConfig.java b/financer-web-client/src/main/java/de/financer/config/FinancerConfig.java index dcd215e..24757a3 100644 --- a/financer-web-client/src/main/java/de/financer/config/FinancerConfig.java +++ b/financer-web-client/src/main/java/de/financer/config/FinancerConfig.java @@ -1,13 +1,15 @@ package de.financer.config; +import de.financer.dto.TransactionUploadFormat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import java.time.DayOfWeek; -import java.util.Currency; +import java.util.*; @Configuration @ConfigurationProperties(prefix = "financer") @@ -22,6 +24,7 @@ public class FinancerConfig { private Currency currency; private DayOfWeek firstDayOfWeek; private boolean darkMode; + private TransactionUpload transactionUpload; public String getServerUrl() { return serverUrl; @@ -72,4 +75,45 @@ public class FinancerConfig { public void setDarkMode(boolean darkMode) { this.darkMode = darkMode; } + + public TransactionUpload getTransactionUpload() { + return transactionUpload; + } + + public void setTransactionUpload(TransactionUpload transactionUpload) { + this.transactionUpload = transactionUpload; + } + + public static class TransactionUpload { + private Map formats; + + public Map getFormats() { + return formats; + } + + public void setFormats(Map formats) { + this.formats = formats; + } + + public static class Format { + private String dateFormat; + private Locale locale; + + public String getDateFormat() { + return dateFormat; + } + + public void setDateFormat(String dateFormat) { + this.dateFormat = dateFormat; + } + + public Locale getLocale() { + return locale; + } + + public void setLocale(Locale locale) { + this.locale = locale; + } + } + } } diff --git a/financer-web-client/src/main/java/de/financer/config/FormatConverter.java b/financer-web-client/src/main/java/de/financer/config/FormatConverter.java new file mode 100644 index 0000000..a0cf0df --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/config/FormatConverter.java @@ -0,0 +1,27 @@ +package de.financer.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +import java.util.Locale; + +@Component +@ConfigurationPropertiesBinding +public class FormatConverter implements Converter { + @Override + public FinancerConfig.TransactionUpload.Format convert(String source) { + final String[] data = source.split(","); + + if (data.length != 2) { + throw new IllegalArgumentException("Wrong format of Format definition!"); + } + + FinancerConfig.TransactionUpload.Format format = new FinancerConfig.TransactionUpload.Format(); + + format.setDateFormat(data[0]); + format.setLocale(new Locale(data[1])); + + return format; + } +} diff --git a/financer-web-client/src/main/java/de/financer/config/TransactionUploadFormatConverter.java b/financer-web-client/src/main/java/de/financer/config/TransactionUploadFormatConverter.java new file mode 100644 index 0000000..ac88fc5 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/config/TransactionUploadFormatConverter.java @@ -0,0 +1,15 @@ +package de.financer.config; + +import de.financer.dto.TransactionUploadFormat; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationPropertiesBinding +public class TransactionUploadFormatConverter implements Converter { + @Override + public TransactionUploadFormat convert(String source) { + return TransactionUploadFormat.valueOf(source); + } +} 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 fb47553..3ad98c2 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 @@ -22,6 +22,7 @@ public enum Function { TR_EXPENSES_CURRENT_PERIOD("transactions/getExpensesCurrentPeriod"), TR_EXPENSE_PERIOD_TOTALS("transactions/getExpensePeriodTotals"), TR_EXPENSES_ALL_PERIODS("transactions/getExpensesAllPeriods"), + TR_CREATE_UPLOADED_TRANSACTIONS("transactions/upload"), RT_GET_ALL("recurringTransactions/getAll"), RT_GET_ALL_ACTIVE("recurringTransactions/getAllActive"), 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 1ce6ecb..bdf481d 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 @@ -2,19 +2,28 @@ package de.financer.controller; import de.financer.ResponseReason; import de.financer.config.FinancerConfig; +import de.financer.dto.CreateUploadedTransactionsRequestDto; import de.financer.dto.SaveTransactionRequestDto; import de.financer.dto.SearchTransactionsResponseDto; +import de.financer.dto.TransactionUploadFormat; +import de.financer.form.CreateUploadedTransactionForm; import de.financer.form.SearchTransactionsForm; +import de.financer.form.UploadTransactionsForm; import de.financer.model.AccountType; +import de.financer.model.RecurringTransaction; import de.financer.template.*; import de.financer.form.NewTransactionForm; import de.financer.model.Account; import de.financer.template.exception.FinancerRestException; +import de.financer.transactionUpload.TransactionUploadWorker; +import de.financer.transactionUpload.TransactionUploadWorkerFactory; +import de.financer.transactionUpload.UploadedTransaction; import de.financer.util.ControllerUtils; import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -22,10 +31,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; @Controller @@ -34,6 +40,9 @@ public class TransactionController { @Autowired private FinancerConfig financerConfig; + @Autowired + private TransactionUploadWorkerFactory workerFactory; + @GetMapping("/searchTransactions") public String searchTransaction(Model model) { model.addAttribute("form", new SearchTransactionsForm()); @@ -168,4 +177,141 @@ public class TransactionController { return "account/accountDetails"; } + + @GetMapping("/uploadTransactions") + public String uploadTransaction(Model model) { + model.addAttribute("form", new UploadTransactionsForm()); + model.addAttribute("formats", TransactionUploadFormat.values()); + + ControllerUtils.addVersionAttribute(model, this.financerConfig); + ControllerUtils.addCurrencySymbol(model, this.financerConfig); + ControllerUtils.addDarkMode(model, this.financerConfig); + + return "transaction/uploadTransactions"; + } + + @PostMapping("/uploadTransactions") + public String uploadTransactions(UploadTransactionsForm form, Model model) { + // TODO input validation + // TODO ENCODING?? + + try { + final TransactionUploadWorker worker = this.workerFactory + .getWorker(TransactionUploadFormat.valueOf(form.getFormat())); + + final Collection uploadedTransactions = worker.process(form.getFile()); + + return _uploadTransactions(model, Optional.empty(), CreateUploadedTransactionForm.of(uploadedTransactions)); + } + catch(Exception e) { + // TODO + model.addAttribute("errorMessage", "KAPUTT"); + model.addAttribute("formats", TransactionUploadFormat.values()); + model.addAttribute("form", form); + + ControllerUtils.addVersionAttribute(model, this.financerConfig); + ControllerUtils.addCurrencySymbol(model, this.financerConfig); + ControllerUtils.addDarkMode(model, this.financerConfig); + + return "transaction/createUploadedTransactions"; + } + } + + @PostMapping("/createUploadedTransactions") + public String createUploadedTransactions(CreateUploadedTransactionForm form, Model model) { + try { + final Collection dtos = + form.getEntries() + .stream() + .filter(CreateUploadedTransactionForm.CreateUploadedTransactionFormEntry::getCreate) + .map(e -> { + final CreateUploadedTransactionsRequestDto dto = new CreateUploadedTransactionsRequestDto(); + + dto.setAmount(e.getAmount().toString()); + dto.setDate(ControllerUtils.formatDate(this.financerConfig, e.getDate())); + dto.setDescription(e.getDescription()); + dto.setFromAccountKey(e.getFromAccountKey()); + dto.setToAccountKey(e.getToAccountKey()); + dto.setRecurringTransactionId(Optional.ofNullable(e.getRecurringTransactionId()) + .map(id -> id.toString()) + .orElse(null)); + dto.setTaxRelevant(e.getTaxRelevant()); + + if (e.getFile() != null && StringUtils.isNotEmpty(e.getFile().getOriginalFilename())) { + try { + dto.setFileContent(Base64.getEncoder().encodeToString(e.getFile().getBytes())); + dto.setFileName(e.getFile().getOriginalFilename()); + } catch (IOException ioe) { + // TODO No file for us :( + } + } + + return dto; + }) + .collect(Collectors.toList()); + + final ResponseReason responseReason = FinancerRestTemplate + .exchangePost(this.financerConfig, Function.TR_CREATE_UPLOADED_TRANSACTIONS, dtos); + + if (!ResponseReason.CREATED.equals(responseReason)) { + return _uploadTransactions(model, Optional.of(responseReason), form); + } + } + catch(Exception e) { + // TODO + return _uploadTransactions(model, Optional.empty(), form); + } + + return "redirect:/accountOverview"; + } + + private String _uploadTransactions(Model model, Optional responseReason, CreateUploadedTransactionForm form) { + final Iterable allAccounts; + List fromAccounts = new ArrayList<>(); + List toAccounts = new ArrayList<>(); + + try { + allAccounts = FinancerRestTemplate.exchangeGet(this.financerConfig, + Function.ACC_GET_ALL, + new ParameterizedTypeReference>() { + }); + + fromAccounts = ControllerUtils.filterAndSortAccounts(allAccounts).stream() + .filter((a) -> a.getType() != AccountType.EXPENSE) + .collect(Collectors.toList()); + toAccounts = ControllerUtils.filterAndSortAccounts(allAccounts).stream() + .filter((a) -> a.getType() != AccountType.INCOME && a + .getType() != AccountType.START) + .collect(Collectors.toList()); + } catch (FinancerRestException financerRestException) { + // 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("fromAccounts", fromAccounts); + model.addAttribute("toAccounts", toAccounts); + + final ResponseEntity> response = new GetAllActiveRecurringTransactionsTemplate() + .exchange(this.financerConfig); + final List recurringTransactionList = new ArrayList<>(); + final RecurringTransaction emptyRecurring = new RecurringTransaction(); + + emptyRecurring.setDescription("-"); + + recurringTransactionList.add(emptyRecurring); + recurringTransactionList.addAll(IterableUtils.toList(response.getBody())); + + model.addAttribute("recurringTransactions", recurringTransactionList); + + responseReason.ifPresent(rr -> model.addAttribute("errorMessage", rr.name())); + + model.addAttribute("form", form); + + ControllerUtils.addVersionAttribute(model, this.financerConfig); + ControllerUtils.addCurrencySymbol(model, this.financerConfig); + ControllerUtils.addDarkMode(model, this.financerConfig); + + return "transaction/createUploadedTransactions"; + } } diff --git a/financer-web-client/src/main/java/de/financer/form/CreateUploadedTransactionForm.java b/financer-web-client/src/main/java/de/financer/form/CreateUploadedTransactionForm.java new file mode 100644 index 0000000..976def3 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/form/CreateUploadedTransactionForm.java @@ -0,0 +1,123 @@ +package de.financer.form; + +import de.financer.transactionUpload.UploadedTransaction; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class CreateUploadedTransactionForm { + private List entries = new ArrayList<>(); + + public static CreateUploadedTransactionForm of(Collection uploadedTransactions) { + final CreateUploadedTransactionForm form = new CreateUploadedTransactionForm(); + + uploadedTransactions.stream().forEach(e -> form.getEntries().add(CreateUploadedTransactionFormEntry.of(e))); + + return form; + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } + + public static final class CreateUploadedTransactionFormEntry { + private Boolean create; + private String fromAccountKey; + private String toAccountKey; + private Long amount; + private String description; + private String date; + private Boolean taxRelevant; + private MultipartFile file; + private Long recurringTransactionId; + + protected static CreateUploadedTransactionFormEntry of(UploadedTransaction uploadedTransaction) { + final CreateUploadedTransactionFormEntry entry = new CreateUploadedTransactionFormEntry(); + + entry.setCreate(Boolean.TRUE); // Create by default, user can de-select + entry.setAmount(uploadedTransaction.getAmount()); + entry.setDescription(uploadedTransaction.getDescription()); + entry.setDate(uploadedTransaction.getDate()); + + return entry; + } + + 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 Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public Boolean getTaxRelevant() { + return taxRelevant; + } + + public void setTaxRelevant(Boolean taxRelevant) { + this.taxRelevant = taxRelevant; + } + + public MultipartFile getFile() { + return file; + } + + public void setFile(MultipartFile file) { + this.file = file; + } + + public Long getRecurringTransactionId() { + return recurringTransactionId; + } + + public void setRecurringTransactionId(Long recurringTransactionId) { + this.recurringTransactionId = recurringTransactionId; + } + + public Boolean getCreate() { + return create; + } + + public void setCreate(Boolean create) { + this.create = create; + } + } +} diff --git a/financer-web-client/src/main/java/de/financer/form/UploadTransactionsForm.java b/financer-web-client/src/main/java/de/financer/form/UploadTransactionsForm.java new file mode 100644 index 0000000..4cc5794 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/form/UploadTransactionsForm.java @@ -0,0 +1,24 @@ +package de.financer.form; + +import org.springframework.web.multipart.MultipartFile; + +public class UploadTransactionsForm { + private String format; + private MultipartFile file; + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public MultipartFile getFile() { + return file; + } + + public void setFile(MultipartFile file) { + this.file = file; + } +} diff --git a/financer-web-client/src/main/java/de/financer/transactionUpload/AbstractTransactionUploadWorker.java b/financer-web-client/src/main/java/de/financer/transactionUpload/AbstractTransactionUploadWorker.java new file mode 100644 index 0000000..ebefa45 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/transactionUpload/AbstractTransactionUploadWorker.java @@ -0,0 +1,27 @@ +package de.financer.transactionUpload; + +import com.google.common.base.CharMatcher; +import de.financer.config.FinancerConfig; +import de.financer.dto.TransactionUploadFormat; + +public abstract class AbstractTransactionUploadWorker implements TransactionUploadWorker { + private FinancerConfig financerConfig; + private TransactionUploadFormat format; + + protected AbstractTransactionUploadWorker(TransactionUploadFormat format, FinancerConfig financerConfig) { + this.format = format; + this.financerConfig = financerConfig; + } + + protected FinancerConfig.TransactionUpload.Format getFormatConfig() { + return this.financerConfig.getTransactionUpload().getFormats().get(this.format); + } + + protected String removeQuotes(String value) { + return CharMatcher.is('\"').trimFrom(value); + } + + public FinancerConfig getFinancerConfig() { + return financerConfig; + } +} diff --git a/financer-web-client/src/main/java/de/financer/transactionUpload/MT940CSVTransactionUploadWorker.java b/financer-web-client/src/main/java/de/financer/transactionUpload/MT940CSVTransactionUploadWorker.java new file mode 100644 index 0000000..19b549a --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/transactionUpload/MT940CSVTransactionUploadWorker.java @@ -0,0 +1,99 @@ +package de.financer.transactionUpload; + +import de.financer.config.FinancerConfig; +import de.financer.dto.TransactionUploadFormat; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.text.NumberFormat; +import java.text.ParseException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.stream.Collectors; + +public class MT940CSVTransactionUploadWorker extends AbstractTransactionUploadWorker { + /** + * The value date + */ + private static final int DATE_INDEX = 2; + private static final int DESCRIPTION1_INDEX = 3; + private static final int DESCRIPTION2_INDEX = 4; + private static final int AMOUNT_INDEX = 8; + + protected MT940CSVTransactionUploadWorker(FinancerConfig financerConfig) { + super(TransactionUploadFormat.MT940_CSV, financerConfig); + } + + @Override + public Collection process(MultipartFile file) throws Exception { + final Collection retVal = new ArrayList<>(); + + try (InputStream is = file.getInputStream()) { + final CSVParser parser = CSVFormat.DEFAULT.builder() + .setDelimiter(";") + .setNullString(null) + .build() + .parse(new InputStreamReader(is)); + + retVal.addAll(parser.stream().skip(1) + .map(r -> new UploadedTransaction( + // Amount + Math.abs(formatAmount(removeQuotes(r.get(AMOUNT_INDEX)))), + // Description + buildDescription(r), + // Date + formatDate(LocalDate.parse(removeQuotes(r.get(DATE_INDEX)), + DateTimeFormatter.ofPattern(this.getFormatConfig() + .getDateFormat()))) + ) + ) + .collect(Collectors.toList())); + } catch (IOException | RuntimeException e) { + if (e instanceof RuntimeException) { + if (e.getCause() instanceof ParseException) { + // Make it checked again + throw (ParseException) e.getCause(); + } + } + + throw e; + } + + return retVal; + } + + private String formatDate(LocalDate date) { + // Format to common format + return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } + + private String buildDescription(CSVRecord r) { + final String description1 = r.get(DESCRIPTION1_INDEX); + final String description2 = r.get(DESCRIPTION2_INDEX); + + if (StringUtils.isEmpty(description2)) { + return description1; + } + + return description2; + } + + private Long formatAmount(String amountString) { + try { + return NumberFormat.getNumberInstance(this.getFormatConfig().getLocale()) + .parse(amountString) + .longValue(); + } catch (ParseException e) { + // Make it unchecked because of the usage in the lambda pipeline + throw new RuntimeException(e); + } + } +} diff --git a/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorker.java b/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorker.java new file mode 100644 index 0000000..31794ef --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorker.java @@ -0,0 +1,9 @@ +package de.financer.transactionUpload; + +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; + +public interface TransactionUploadWorker { + Collection process(MultipartFile file) throws Exception; +} diff --git a/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorkerFactory.java b/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorkerFactory.java new file mode 100644 index 0000000..c0b9542 --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/transactionUpload/TransactionUploadWorkerFactory.java @@ -0,0 +1,27 @@ +package de.financer.transactionUpload; + +import de.financer.config.FinancerConfig; +import de.financer.dto.TransactionUploadFormat; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TransactionUploadWorkerFactory { + @Autowired + private FinancerConfig financerConfig; + + public TransactionUploadWorker getWorker(TransactionUploadFormat format) { + AbstractTransactionUploadWorker worker; + + switch (format) { + case MT940_CSV: + worker = new MT940CSVTransactionUploadWorker(this.financerConfig); + + break; + default: + worker = null; + } + + return worker; + } +} diff --git a/financer-web-client/src/main/java/de/financer/transactionUpload/UploadedTransaction.java b/financer-web-client/src/main/java/de/financer/transactionUpload/UploadedTransaction.java new file mode 100644 index 0000000..8bfa49e --- /dev/null +++ b/financer-web-client/src/main/java/de/financer/transactionUpload/UploadedTransaction.java @@ -0,0 +1,37 @@ +package de.financer.transactionUpload; + +public class UploadedTransaction { + private Long amount; + private String description; + private String date; + + public UploadedTransaction(Long amount, String description, String date) { + this.amount = amount; + this.description = description; + this.date = date; + } + + public Long getAmount() { + return amount; + } + + public void setAmount(Long amount) { + this.amount = amount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } +} diff --git a/financer-web-client/src/main/resources/config/application.properties b/financer-web-client/src/main/resources/config/application.properties index d6c46f8..84873e7 100644 --- a/financer-web-client/src/main/resources/config/application.properties +++ b/financer-web-client/src/main/resources/config/application.properties @@ -43,4 +43,23 @@ financer.firstDayOfWeek=MONDAY # Whether dark mode should be enabled by default # Possible values: true|default -financer.darkMode=true \ No newline at end of file +financer.darkMode=true + +# Transaction upload format configs +# Spring Boot does not support dynamic properties so we cannot use for example +# financer.transactionUpload.MT940_CSV.dateFormat=dd.MM.yy +# financer.transactionUpload.MT940_CSV.locale=de_DE +# financer.transactionUpload.XXX.dateFormat=yyyy-MM-dd +# financer.transactionUpload.XXX.locale=en_US +# So we have to fallback to some ugly list-style property. This is handled by some converter that +# transforms the list-style property's value into a pojo. +# +# financer.transactionUpload.formats.YYY= +# \ / +# v +# key -> constant in the TransactionUploadFormat enum +# +# The list-style property has to be compliant to the format +# 1 -> date format +# 2 -> locale +financer.transactionUpload.formats.MT940_CSV=dd.MM.yy,de_DE \ No newline at end of file diff --git a/financer-web-client/src/main/resources/i18n/message.properties b/financer-web-client/src/main/resources/i18n/message.properties index ec3161e..bd3118a 100644 --- a/financer-web-client/src/main/resources/i18n/message.properties +++ b/financer-web-client/src/main/resources/i18n/message.properties @@ -5,6 +5,7 @@ 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.upload-transactions=Upload 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 @@ -163,6 +164,30 @@ financer.search-transactions.show-query-options.like=Fuzzy search with LIKE, % a financer.search-transactions.show-query-options.in=Multiple string or integer values via IN ('value1', 'value2', ...) financer.search-transactions.show-query-options.between=Filtering of values (string, int, date) in a given range, start and end inclusive, via BETWEEN +financer.upload-transactions.title=financer\: upload transactions +financer.upload-transactions.label.format=Format\: +financer.upload-transactions.format.MT940_CSV=MT940 CSV +financer.upload-transactions.label.file=File\: +financer.upload-transactions.submit=Upload transactions + +financer.create-upload-transactions.title=financer\: create uploaded transactions +financer.create-upload-transactions.table-header.create=Create +financer.create-upload-transactions.table-header.fromAccount=From account +financer.create-upload-transactions.table-header.toAccount=To account +financer.create-upload-transactions.table-header.date=Date +financer.create-upload-transactions.table-header.amount=Amount +financer.create-upload-transactions.table-header.description=Description +financer.create-upload-transactions.table-header.recurring-transaction=Recurring transaction +financer.create-upload-transactions.table-header.taxRelevant=Tax relevant +financer.create-upload-transactions.table-header.file=File +financer.create-upload-transactions.account-type.BANK={0}|Bank|{1}{2} +financer.create-upload-transactions.account-type.CASH={0}|Cash|{1}{2} +financer.create-upload-transactions.account-type.INCOME={0}|Income|{1}{2} +financer.create-upload-transactions.account-type.LIABILITY={0}|Liability|{1}{2} +financer.create-upload-transactions.account-type.EXPENSE={0}|Expense|{1}{2} +financer.create-upload-transactions.account-type.START={0}|Start|{1}{2} +financer.create-upload-transactions.submit=Create uploaded transactions + financer.chart-select.title=Select a chart to generate financer.chart-select.submit=Select @@ -235,6 +260,8 @@ financer.heading.chart-config-account-expenses-for-period=financer\: configure a financer.heading.search-transactions=financer\: search transactions financer.heading.recurring-transaction-calendar=financer\: recurring transaction calendar financer.heading.period-overview=financer\: period overview +financer.heading.upload-transactions=financer\: upload transactions +financer.heading.create-upload-transactions=financer\: create uploaded transactions financer.cancel-back-to-overview=Cancel and back to overview financer.back-to-overview=Back to overview 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 af9747c..a6a19a4 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 @@ -5,6 +5,7 @@ financer.account-overview.available-actions.hide-closed=Verstecke geschlossene K 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.upload-transactions=Buchungen hochladen 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 @@ -163,6 +164,30 @@ financer.search-transactions.show-query-options.like=Platzhaltersuche mit LIKE, financer.search-transactions.show-query-options.in=Mehrere Zeichenketten- oder Ganzzahlwerte mit IN ('wert1', 'wert2', ...) financer.search-transactions.show-query-options.between=Filtern von Werten (Zeichenketten, Ganzzahlen, Daten) in einem Bereich, Start und Ende jeweils inklusive, mit BETWEEN +financer.upload-transactions.title=financer\: Buchungen hochladen +financer.upload-transactions.label.format=Format\: +financer.upload-transactions.format.MT940_CSV=MT940 CSV +financer.upload-transactions.label.file=Datei\: +financer.upload-transactions.submit=Buchungen hochladen + +financer.create-upload-transactions.title=financer\: Erstelle hochgeladene Buchungen +financer.create-upload-transactions.table-header.create=Erstellen +financer.create-upload-transactions.table-header.fromAccount=Von Konto +financer.create-upload-transactions.table-header.toAccount=An Konto +financer.create-upload-transactions.table-header.date=Datum +financer.create-upload-transactions.table-header.amount=Betrag +financer.create-upload-transactions.table-header.description=Beschreibung +financer.create-upload-transactions.table-header.recurring-transaction=Wiederkehrende Buchung +financer.create-upload-transactions.table-header.taxRelevant=Relevant f\u00FCr Steuererkl\u00E4rung +financer.create-upload-transactions.table-header.file=Datei +financer.create-upload-transactions.account-type.BANK={0}|Bank|{1}{2} +financer.create-upload-transactions.account-type.CASH={0}|Bar|{1}{2} +financer.create-upload-transactions.account-type.INCOME={0}|Einkommen|{1}{2} +financer.create-upload-transactions.account-type.LIABILITY={0}|Verbindlichkeit|{1}{2} +financer.create-upload-transactions.account-type.EXPENSE={0}|Aufwand|{1}{2} +financer.create-upload-transactions.account-type.START={0}|Anfangsbestand|{1}{2} +financer.create-upload-transactions.submit=Erstelle hochgeladene Buchungen + financer.chart-select.title=Ein Diagramm zum Erzeugen ausw\u00E4hlen financer.chart-select.submit=Ausw\u00E4hlen @@ -234,6 +259,8 @@ financer.heading.chart-config-account-group-expenses-for-period=financer\: Konfi financer.heading.search-transactions=financer\: Buchungen suchen financer.heading.recurring-transaction-calendar=financer\: Kalender wiederkehrende Buchung financer.heading.period-overview=financer\: Perioden\u00FCbersicht +financer.heading.upload-transactions=financer\: Buchungen hochladen +financer.heading.create-upload-transactions=financer\: Erstelle hochgeladene Buchungen financer.cancel-back-to-overview=Abbrechen und zur\u00FCck zur \u00DCbersicht financer.back-to-overview=Zur\u00FCck zur \u00DCbersicht diff --git a/financer-web-client/src/main/resources/static/changelog.txt b/financer-web-client/src/main/resources/static/changelog.txt index 624cb8e..ab4510c 100644 --- a/financer-web-client/src/main/resources/static/changelog.txt +++ b/financer-web-client/src/main/resources/static/changelog.txt @@ -1,6 +1,8 @@ v47 -> v48: - Added new property 'transaction type' to a transaction, denoting the type of the transaction, e.g. asset swap, expense, liability or income. This can also be queried via FQL +- #22 Added new feature to upload a file that contains transactions, e.g. a file export from online banking. Currently + supported is the MT940_CSV format v46 -> v47: - Fix a bug that occurred while creating a transaction from a recurring transaction with amount overwrite diff --git a/financer-web-client/src/main/resources/static/css/main.css b/financer-web-client/src/main/resources/static/css/main.css index 885cff3..59c678a 100644 --- a/financer-web-client/src/main/resources/static/css/main.css +++ b/financer-web-client/src/main/resources/static/css/main.css @@ -71,7 +71,8 @@ a { #account-overview-table, #transaction-table, #recurring-transaction-list-table, -#period-overview-table { +#period-overview-table, +#create-upload-transactions-table { width: 100%; border-collapse: collapse; text-align: left; @@ -86,7 +87,9 @@ a { #recurring-transaction-list-table th, #recurring-transaction-list-table td, #period-overview-table th, -#period-overview-table td { +#period-overview-table td, +#create-upload-transactions-table th, +#create-upload-transactions-table td { border-bottom: 1px solid var(--border-color); padding: 0.3em; vertical-align: top; @@ -95,7 +98,8 @@ a { #account-overview-table th, #transaction-table th, #recurring-transaction-list-table th, -#period-overview-table th { +#period-overview-table th, +#create-upload-transactions-table th{ position: sticky; top: 0px; background-color: var(--background-color); @@ -170,7 +174,8 @@ tr:hover { #new-account-group-form *, #chart-config-account-group-expenses-for-period-form *, #chart-config-account-expenses-for-period-form *, -#search-transactions-form * { +#search-transactions-form *, +#upload-transactions-form * { display: block; margin-top: 1em; width: 20em; @@ -181,6 +186,14 @@ tr:hover { width: 100% !important; } +#create-upload-transactions-table > * > * select { + width: 18em; +} + +#create-upload-transactions-amount { + width: 5em; +} + #chart-select-form div { width: 20em; margin-top: 1em; diff --git a/financer-web-client/src/main/resources/templates/account/accountOverview.html b/financer-web-client/src/main/resources/templates/account/accountOverview.html index e39f50c..a6a4a2d 100644 --- a/financer-web-client/src/main/resources/templates/account/accountOverview.html +++ b/financer-web-client/src/main/resources/templates/account/accountOverview.html @@ -54,6 +54,7 @@
+ + + + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/> + <link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/> + <link rel="stylesheet" th:href="@{/css/main.css}"> + <link rel="shortcut icon" th:href="@{/favicon.ico}"/> +</head> +<body> +<h1 th:text="#{financer.heading.create-upload-transactions}"/> +<span class="errorMessage" th:if="${errorMessage != null}" th:text="#{'financer.error-message.' + ${errorMessage}}"/> +<a th:href="@{/accountOverview}" th:text="#{financer.cancel-back-to-overview}"/> +<form id="create-upload-transactions-form" action="#" th:action="@{/createUploadedTransactions}" th:object="${form}" + method="post" enctype="multipart/form-data"> + <table id="create-upload-transactions-table"> + <tr> + <th th:text="#{financer.create-upload-transactions.table-header.create}"/> + <th th:text="#{financer.create-upload-transactions.table-header.fromAccount}"/> + <th th:text="#{financer.create-upload-transactions.table-header.toAccount}"/> + <th th:text="#{financer.create-upload-transactions.table-header.date}"/> + <th th:text="#{financer.create-upload-transactions.table-header.amount}"/> + <th th:text="#{financer.create-upload-transactions.table-header.description}"/> + <th th:text="#{financer.create-upload-transactions.table-header.recurring-transaction}"/> + <th th:text="#{financer.create-upload-transactions.table-header.taxRelevant}"/> + <th th:text="#{financer.create-upload-transactions.table-header.file}"/> + </tr> + <tr th:each="entry, i : ${form.entries}"> + <td> + <input type="checkbox" th:field="*{entries[__${i.index}__].create}"/> + </td> + <td> + <select th:field="*{entries[__${i.index}__].fromAccountKey}"> + <option th:each="acc : ${fromAccounts}" th:value="${acc.key}" + th:utext="#{'financer.create-upload-transactions.account-type.' + ${acc.type}(${acc.key},${#numbers.formatDecimal(acc.currentBalance/100D, 1, 'DEFAULT', 2, 'DEFAULT')},${currencySymbol})}"/> + </select> + </td> + <td> + <select th:field="*{entries[__${i.index}__].toAccountKey}"> + <option th:each="acc : ${toAccounts}" th:value="${acc.key}" + th:utext="#{'financer.create-upload-transactions.account-type.' + ${acc.type}(${acc.key},${#numbers.formatDecimal(acc.currentBalance/100D, 1, 'DEFAULT', 2, 'DEFAULT')},${currencySymbol})}"/> + </select> + </td> + <td> + <input type="date" th:field="*{entries[__${i.index}__].date}"/> + </td> + <td> + <input id="create-upload-transactions-amount" type="text" th:field="*{entries[__${i.index}__].amount}"/> + </td> + <td> + <input type="text" th:field="*{entries[__${i.index}__].description}"/> + </td> + <td> + <select th:field="*{entries[__${i.index}__].recurringTransactionId}"> + <option th:each="rt : ${recurringTransactions}" th:value="${rt.id}" + th:text="${rt.description}"/> + </select> + </td> + <td> + <input type="checkbox" th:field="*{entries[__${i.index}__].taxRelevant}"/> + </td> + <td> + <input type="file" th:field="*{entries[__${i.index}__].file}"/> + </td> + </tr> + </table> + <input type="submit" th:value="#{financer.create-upload-transactions.submit}"/> +</form> +<div th:replace="includes/footer :: footer"/> +</body> +</html> \ No newline at end of file diff --git a/financer-web-client/src/main/resources/templates/transaction/uploadTransactions.html b/financer-web-client/src/main/resources/templates/transaction/uploadTransactions.html new file mode 100644 index 0000000..04c9e6e --- /dev/null +++ b/financer-web-client/src/main/resources/templates/transaction/uploadTransactions.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html xmlns:th="http://www.thymeleaf.org"> +<head> + <title th:text="#{financer.upload-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 th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}" /> + <link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}" /> + <link rel="stylesheet" th:href="@{/css/main.css}"> + <link rel="shortcut icon" th:href="@{/favicon.ico}" /> +</head> +<body> +<h1 th:text="#{financer.heading.upload-transactions}" /> +<span class="errorMessage" th:if="${errorMessage != null}" th:text="#{'financer.error-message.' + ${errorMessage}}"/> +<a th:href="@{/accountOverview}" th:text="#{financer.cancel-back-to-overview}"/> +<form id="upload-transactions-form" action="#" th:action="@{/uploadTransactions}" th:object="${form}" + method="post" enctype="multipart/form-data"> + <label for="selectFormat" th:text="#{financer.upload-transactions.label.format}"/> + <select id="selectFormat" th:field="*{format}"> + <option th:each="format : ${formats}" th:value="${format}" + th:utext="#{'financer.upload-transactions.format.' + ${format}}"/> + </select> + <label for="inputFile" th:text="#{financer.upload-transactions.label.file}" /> + <input type="file" id="inputFile" th:field="*{file}" /> + <input type="submit" th:value="#{financer.upload-transactions.submit}" /> +</form> +<div th:replace="includes/footer :: footer" /> +</body> +</html> \ No newline at end of file