#22 Add transaction upload

This commit is contained in:
2021-08-25 21:41:00 +02:00
parent 0bb534c8b4
commit b2d1b8572e
26 changed files with 957 additions and 10 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
package de.financer.dto;
public enum TransactionUploadFormat {
MT940_CSV;
}

View File

@@ -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<CreateUploadedTransactionsRequestDto> 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();
}
}

View File

@@ -60,6 +60,10 @@ public class RecurringTransactionService {
return this.recurringTransactionRepository.findAllActive(LocalDate.now());
}
protected Optional<RecurringTransaction> findById(Long id) {
return this.recurringTransactionRepository.findById(id);
}
public Iterable<RecurringTransaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(accountKey);

View File

@@ -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 <code>null</code>
*/
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason createTransactions(Collection<CreateUploadedTransactionsRequestDto> 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;
}
}

View File

@@ -48,6 +48,11 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.9.0</version>
</dependency>
<!-- Misc dependencies -->
<dependency>
<groupId>org.jfree</groupId>

View File

@@ -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<TransactionUploadFormat, Format> formats;
public Map<TransactionUploadFormat, Format> getFormats() {
return formats;
}
public void setFormats(Map<TransactionUploadFormat, Format> 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;
}
}
}
}

View File

@@ -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<String, FinancerConfig.TransactionUpload.Format> {
@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;
}
}

View File

@@ -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<String, TransactionUploadFormat> {
@Override
public TransactionUploadFormat convert(String source) {
return TransactionUploadFormat.valueOf(source);
}
}

View File

@@ -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"),

View File

@@ -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<UploadedTransaction> 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<CreateUploadedTransactionsRequestDto> 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> responseReason, CreateUploadedTransactionForm form) {
final Iterable<Account> allAccounts;
List<Account> fromAccounts = new ArrayList<>();
List<Account> toAccounts = new ArrayList<>();
try {
allAccounts = FinancerRestTemplate.exchangeGet(this.financerConfig,
Function.ACC_GET_ALL,
new ParameterizedTypeReference<Iterable<Account>>() {
});
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<Iterable<RecurringTransaction>> response = new GetAllActiveRecurringTransactionsTemplate()
.exchange(this.financerConfig);
final List<RecurringTransaction> 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";
}
}

View File

@@ -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<CreateUploadedTransactionFormEntry> entries = new ArrayList<>();
public static CreateUploadedTransactionForm of(Collection<UploadedTransaction> uploadedTransactions) {
final CreateUploadedTransactionForm form = new CreateUploadedTransactionForm();
uploadedTransactions.stream().forEach(e -> form.getEntries().add(CreateUploadedTransactionFormEntry.of(e)));
return form;
}
public List<CreateUploadedTransactionFormEntry> getEntries() {
return entries;
}
public void setEntries(List<CreateUploadedTransactionFormEntry> 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<UploadedTransaction> process(MultipartFile file) throws Exception {
final Collection<UploadedTransaction> 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);
}
}
}

View File

@@ -0,0 +1,9 @@
package de.financer.transactionUpload;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection;
public interface TransactionUploadWorker {
Collection<UploadedTransaction> process(MultipartFile file) throws Exception;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -44,3 +44,22 @@ financer.firstDayOfWeek=MONDAY
# Whether dark mode should be enabled by default
# Possible values: true|default
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,73 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{financer.create-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.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>

View File

@@ -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>