#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; package de.financer.controller;
import de.financer.ResponseReason; import de.financer.ResponseReason;
import de.financer.dto.CreateUploadedTransactionsRequestDto;
import de.financer.dto.ExpensePeriodTotal; import de.financer.dto.ExpensePeriodTotal;
import de.financer.dto.SaveTransactionRequestDto; import de.financer.dto.SaveTransactionRequestDto;
import de.financer.dto.SearchTransactionsResponseDto; import de.financer.dto.SearchTransactionsResponseDto;
@@ -12,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Collection;
import java.util.List; import java.util.List;
@RestController @RestController
@@ -164,4 +166,19 @@ public class TransactionController {
return response; 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()); return this.recurringTransactionRepository.findAllActive(LocalDate.now());
} }
protected Optional<RecurringTransaction> findById(Long id) {
return this.recurringTransactionRepository.findById(id);
}
public Iterable<RecurringTransaction> getAllForAccount(String accountKey) { public Iterable<RecurringTransaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(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.ResponseReason;
import de.financer.config.FinancerConfig; import de.financer.config.FinancerConfig;
import de.financer.dba.TransactionRepository; import de.financer.dba.TransactionRepository;
import de.financer.dto.CreateUploadedTransactionsRequestDto;
import de.financer.dto.ExpensePeriodTotal; import de.financer.dto.ExpensePeriodTotal;
import de.financer.dto.Order; import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto; import de.financer.dto.SearchTransactionsResponseDto;
@@ -44,6 +45,9 @@ public class TransactionService {
@Autowired @Autowired
private TransactionRepository transactionRepository; private TransactionRepository transactionRepository;
@Autowired
private RecurringTransactionService recurringTransactionService;
@Autowired @Autowired
private FinancerConfig financerConfig; private FinancerConfig financerConfig;
@@ -411,4 +415,54 @@ public class TransactionService {
throw new FinancerServiceException(ResponseReason.FQL_MALFORMED); 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> <groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId> <artifactId>commons-collections4</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.9.0</version>
</dependency>
<!-- Misc dependencies --> <!-- Misc dependencies -->
<dependency> <dependency>
<groupId>org.jfree</groupId> <groupId>org.jfree</groupId>

View File

@@ -1,13 +1,15 @@
package de.financer.config; package de.financer.config;
import de.financer.dto.TransactionUploadFormat;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties; 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.ComponentScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.util.Currency; import java.util.*;
@Configuration @Configuration
@ConfigurationProperties(prefix = "financer") @ConfigurationProperties(prefix = "financer")
@@ -22,6 +24,7 @@ public class FinancerConfig {
private Currency currency; private Currency currency;
private DayOfWeek firstDayOfWeek; private DayOfWeek firstDayOfWeek;
private boolean darkMode; private boolean darkMode;
private TransactionUpload transactionUpload;
public String getServerUrl() { public String getServerUrl() {
return serverUrl; return serverUrl;
@@ -72,4 +75,45 @@ public class FinancerConfig {
public void setDarkMode(boolean darkMode) { public void setDarkMode(boolean darkMode) {
this.darkMode = 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_EXPENSES_CURRENT_PERIOD("transactions/getExpensesCurrentPeriod"),
TR_EXPENSE_PERIOD_TOTALS("transactions/getExpensePeriodTotals"), TR_EXPENSE_PERIOD_TOTALS("transactions/getExpensePeriodTotals"),
TR_EXPENSES_ALL_PERIODS("transactions/getExpensesAllPeriods"), TR_EXPENSES_ALL_PERIODS("transactions/getExpensesAllPeriods"),
TR_CREATE_UPLOADED_TRANSACTIONS("transactions/upload"),
RT_GET_ALL("recurringTransactions/getAll"), RT_GET_ALL("recurringTransactions/getAll"),
RT_GET_ALL_ACTIVE("recurringTransactions/getAllActive"), RT_GET_ALL_ACTIVE("recurringTransactions/getAllActive"),

View File

@@ -2,19 +2,28 @@ package de.financer.controller;
import de.financer.ResponseReason; import de.financer.ResponseReason;
import de.financer.config.FinancerConfig; import de.financer.config.FinancerConfig;
import de.financer.dto.CreateUploadedTransactionsRequestDto;
import de.financer.dto.SaveTransactionRequestDto; import de.financer.dto.SaveTransactionRequestDto;
import de.financer.dto.SearchTransactionsResponseDto; import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.dto.TransactionUploadFormat;
import de.financer.form.CreateUploadedTransactionForm;
import de.financer.form.SearchTransactionsForm; import de.financer.form.SearchTransactionsForm;
import de.financer.form.UploadTransactionsForm;
import de.financer.model.AccountType; import de.financer.model.AccountType;
import de.financer.model.RecurringTransaction;
import de.financer.template.*; import de.financer.template.*;
import de.financer.form.NewTransactionForm; import de.financer.form.NewTransactionForm;
import de.financer.model.Account; import de.financer.model.Account;
import de.financer.template.exception.FinancerRestException; 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 de.financer.util.ControllerUtils;
import org.apache.commons.collections4.IterableUtils; import org.apache.commons.collections4.IterableUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; 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 org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.*;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Controller @Controller
@@ -34,6 +40,9 @@ public class TransactionController {
@Autowired @Autowired
private FinancerConfig financerConfig; private FinancerConfig financerConfig;
@Autowired
private TransactionUploadWorkerFactory workerFactory;
@GetMapping("/searchTransactions") @GetMapping("/searchTransactions")
public String searchTransaction(Model model) { public String searchTransaction(Model model) {
model.addAttribute("form", new SearchTransactionsForm()); model.addAttribute("form", new SearchTransactionsForm());
@@ -168,4 +177,141 @@ public class TransactionController {
return "account/accountDetails"; 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

@@ -43,4 +43,23 @@ financer.firstDayOfWeek=MONDAY
# Whether dark mode should be enabled by default # Whether dark mode should be enabled by default
# Possible values: true|default # Possible values: true|default
financer.darkMode=true 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-account=Create new account
financer.account-overview.available-actions.create-transaction=Create new transaction financer.account-overview.available-actions.create-transaction=Create new transaction
financer.account-overview.available-actions.search-transactions=Search transactions 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.create-recurring-transaction=Create new recurring transaction
financer.account-overview.available-actions.recurring-transaction-all=Show all recurring transactions financer.account-overview.available-actions.recurring-transaction-all=Show all recurring transactions
financer.account-overview.available-actions.create-account-group=Create new account group 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.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.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.title=Select a chart to generate
financer.chart-select.submit=Select 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.search-transactions=financer\: search transactions
financer.heading.recurring-transaction-calendar=financer\: recurring transaction calendar financer.heading.recurring-transaction-calendar=financer\: recurring transaction calendar
financer.heading.period-overview=financer\: period overview 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.cancel-back-to-overview=Cancel and back to overview
financer.back-to-overview=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-account=Neues Konto erstellen
financer.account-overview.available-actions.create-transaction=Neue Buchung 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.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.create-recurring-transaction=Neue wiederkehrende Buchung erstellen
financer.account-overview.available-actions.recurring-transaction-all=Zeige alle wiederkehrende Buchungen financer.account-overview.available-actions.recurring-transaction-all=Zeige alle wiederkehrende Buchungen
financer.account-overview.available-actions.create-account-group=Neue Konto-Gruppe erstellen 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.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.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.title=Ein Diagramm zum Erzeugen ausw\u00E4hlen
financer.chart-select.submit=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.search-transactions=financer\: Buchungen suchen
financer.heading.recurring-transaction-calendar=financer\: Kalender wiederkehrende Buchung financer.heading.recurring-transaction-calendar=financer\: Kalender wiederkehrende Buchung
financer.heading.period-overview=financer\: Perioden\u00FCbersicht 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.cancel-back-to-overview=Abbrechen und zur\u00FCck zur \u00DCbersicht
financer.back-to-overview=Zur\u00FCck zur \u00DCbersicht financer.back-to-overview=Zur\u00FCck zur \u00DCbersicht

View File

@@ -1,6 +1,8 @@
v47 -> v48: v47 -> v48:
- Added new property 'transaction type' to a transaction, denoting the type of the transaction, e.g. asset swap, - 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 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: v46 -> v47:
- Fix a bug that occurred while creating a transaction from a recurring transaction with amount overwrite - 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, #account-overview-table,
#transaction-table, #transaction-table,
#recurring-transaction-list-table, #recurring-transaction-list-table,
#period-overview-table { #period-overview-table,
#create-upload-transactions-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
text-align: left; text-align: left;
@@ -86,7 +87,9 @@ a {
#recurring-transaction-list-table th, #recurring-transaction-list-table th,
#recurring-transaction-list-table td, #recurring-transaction-list-table td,
#period-overview-table th, #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); border-bottom: 1px solid var(--border-color);
padding: 0.3em; padding: 0.3em;
vertical-align: top; vertical-align: top;
@@ -95,7 +98,8 @@ a {
#account-overview-table th, #account-overview-table th,
#transaction-table th, #transaction-table th,
#recurring-transaction-list-table th, #recurring-transaction-list-table th,
#period-overview-table th { #period-overview-table th,
#create-upload-transactions-table th{
position: sticky; position: sticky;
top: 0px; top: 0px;
background-color: var(--background-color); background-color: var(--background-color);
@@ -170,7 +174,8 @@ tr:hover {
#new-account-group-form *, #new-account-group-form *,
#chart-config-account-group-expenses-for-period-form *, #chart-config-account-group-expenses-for-period-form *,
#chart-config-account-expenses-for-period-form *, #chart-config-account-expenses-for-period-form *,
#search-transactions-form * { #search-transactions-form *,
#upload-transactions-form * {
display: block; display: block;
margin-top: 1em; margin-top: 1em;
width: 20em; width: 20em;
@@ -181,6 +186,14 @@ tr:hover {
width: 100% !important; width: 100% !important;
} }
#create-upload-transactions-table > * > * select {
width: 18em;
}
#create-upload-transactions-amount {
width: 5em;
}
#chart-select-form div { #chart-select-form div {
width: 20em; width: 20em;
margin-top: 1em; margin-top: 1em;

View File

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