Changes for v26

This commit is contained in:
2020-01-20 23:29:35 +01:00
parent 8cec43ee91
commit 95c1c0e7d6
27 changed files with 713 additions and 38 deletions

View File

@@ -6,7 +6,19 @@ import java.util.stream.Collectors;
public enum PeriodType {
/** User defined start and end */
EXPENSE;
EXPENSE,
/**
* Like {@link PeriodType#EXPENSE} but for a whole year - this may <b>not</b> be a calendar year,
* if e.g. the EXPENSE periods do not start at the first of every month.
* This includes all periods that start the in the year.
*/
EXPENSE_YEAR,
/**
* Denotes a continuous, cumulative period, starting with the inception of the financer app, without an end.
*/
GRAND_TOTAL;
/**
* This method validates whether the given string represents a valid period type.

View File

@@ -0,0 +1,177 @@
package database.common;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Date;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class V26_0_0__createGrandTotalPeriod extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;
this.toAccountId = toAccountId;
this.id = id;
}
}
private static final String GET_CONTAINERS_SQL = "SELECT t.amount, t.from_account_id, t.to_account_id, t.id FROM \"transaction\" t";
private static final String ACCOUNT_STATISTIC_EXISTS_SQL = "SELECT id FROM account_statistic WHERE account_id = ? AND period_id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_FROM_SQL = "UPDATE account_statistic SET spending_total_from = spending_total_from + ?, transaction_count_from = transaction_count_from + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_TO_SQL = "UPDATE account_statistic SET spending_total_to = spending_total_to + ?, transaction_count_to = transaction_count_to + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_INSERT_FROM_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, ?, 1, 0, 0)";
private static final String ACCOUNT_STATISTIC_INSERT_TO_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, 0, 0, ?, 1)";
private static final String SELECT_DATE_OLDEST_TRANSACTION_SQL = "SELECT \"date\" FROM \"transaction\" ORDER BY \"date\" ASC";
private static final String INSERT_GRAND_TOTAL_PERIOD_SQL = "INSERT INTO period (type, start) VALUES ('GRAND_TOTAL', ?)";
private static final String SELECT_GRAND_TOTAL_PERIOD_SQL = "SELECT id FROM period WHERE type = 'GRAND_TOTAL'";
private static final String INSERT_LINK_TRANSACTION_PERIOD_SQL = "INSERT INTO link_transaction_period (transaction_id, period_id) VALUES (?, ?)";
@Override
public void migrate(Context context) throws Exception {
final long grandTotalPeriodId = createGrandTotalPeriod(context);
final List<TransactionAccountsContainer> cons = getContainers(context);
for (TransactionAccountsContainer con : cons) {
handleAccount(con, context, grandTotalPeriodId, true);
handleAccount(con, context, grandTotalPeriodId, false);
handleTransaction(con, context, grandTotalPeriodId);
}
}
private long createGrandTotalPeriod(Context context) throws Exception {
// Check whether there are transactions from which we can derive the inception date
// If so, use the date of the oldest transaction to create the GRAND_TOTAL period
// If not, use the current date to create the GRAND_TOTAL period
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(SELECT_DATE_OLDEST_TRANSACTION_SQL)) {
statement.setMaxRows(1);
final ResultSet rs = statement.executeQuery();
final Date inceptionDate;
// The ResultSet contains only one entry as specified with the setMaxRows(1) call above
if (rs.next()) {
inceptionDate = rs.getDate(1);
}
else {
inceptionDate = new Date(System.currentTimeMillis());
}
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_GRAND_TOTAL_PERIOD_SQL)) {
statementInsert.setDate(1, inceptionDate);
statementInsert.execute();
try (PreparedStatement statementSelect =
context
.getConnection()
.prepareStatement(SELECT_GRAND_TOTAL_PERIOD_SQL)) {
final ResultSet rsSelect = statementSelect.executeQuery();
// We know it's there as we just created it
rsSelect.next();
return rsSelect.getLong(1);
}
}
}
}
private List<TransactionAccountsContainer> getContainers(Context context) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(GET_CONTAINERS_SQL)) {
final ResultSet rs = statement.executeQuery();
final List<TransactionAccountsContainer> cons = new ArrayList<>();
while(rs.next()) {
cons.add(new TransactionAccountsContainer(
rs.getLong(1), // transaction amount
rs.getLong(2), // from account ID
rs.getLong(3), // to account ID
rs.getLong(4) // ID
));
}
return cons;
}
}
private void handleAccount(TransactionAccountsContainer con, Context context, long grandTotalPeriodId, boolean from) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(ACCOUNT_STATISTIC_EXISTS_SQL)) {
statement.setLong(1, from ? con.fromAccountId : con.toAccountId);
statement.setLong(2, grandTotalPeriodId);
final ResultSet rs = statement.executeQuery();
boolean found = false;
// Only one possible entry because of the unique constraint
while(rs.next()) {
found = true;
try (PreparedStatement statementUpdate =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_UPDATE_FROM_SQL : ACCOUNT_STATISTIC_UPDATE_TO_SQL)) {
statementUpdate.setLong(1, con.transactionAmount);
statementUpdate.setLong(2, rs.getLong(1));
statementUpdate.executeUpdate();
}
}
// We need to create a new account_statistic entry for the tuple {account_id;period_id}
if (!found) {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_INSERT_FROM_SQL : ACCOUNT_STATISTIC_INSERT_TO_SQL)) {
statementInsert.setLong(1, from ? con.fromAccountId : con.toAccountId);
statementInsert.setLong(2, grandTotalPeriodId);
statementInsert.setLong(3, con.transactionAmount);
statementInsert.execute();
}
}
}
}
private void handleTransaction(TransactionAccountsContainer con, Context context, long grandTotalPeriodId) throws SQLException {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_LINK_TRANSACTION_PERIOD_SQL)) {
statementInsert.setLong(1, con.id);
statementInsert.setLong(2, grandTotalPeriodId);
statementInsert.execute();
}
}
}

View File

@@ -0,0 +1,200 @@
package database.common;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.*;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
public class V26_0_1__createExpenseYearPeriod_2019 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;
this.toAccountId = toAccountId;
this.id = id;
}
}
private static final String GET_CONTAINERS_SQL = "SELECT t.amount, t.from_account_id, t.to_account_id, t.id FROM \"transaction\" t INNER JOIN link_transaction_period ltp ON ltp.transaction_id = t.id INNER JOIN period p ON p.id = ltp.period_id WHERE p.type = 'EXPENSE' AND t.\"date\" >= ? AND t.\"date\" < ?";
private static final String ACCOUNT_STATISTIC_EXISTS_SQL = "SELECT id FROM account_statistic WHERE account_id = ? AND period_id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_FROM_SQL = "UPDATE account_statistic SET spending_total_from = spending_total_from + ?, transaction_count_from = transaction_count_from + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_TO_SQL = "UPDATE account_statistic SET spending_total_to = spending_total_to + ?, transaction_count_to = transaction_count_to + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_INSERT_FROM_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, ?, 1, 0, 0)";
private static final String ACCOUNT_STATISTIC_INSERT_TO_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, 0, 0, ?, 1)";
private static final String SELECT_FIRST_PERIOD_SQL = "SELECT start FROM period WHERE type = 'EXPENSE' AND start BETWEEN '2019-01-01' AND '2019-12-31' ORDER BY start ASC";
private static final String SELECT_LAST_PERIOD_SQL = "SELECT \"end\" FROM period WHERE type = 'EXPENSE' AND start BETWEEN '2019-01-01' AND '2019-12-31' ORDER BY start DESC";
private static final String INSERT_EXPENSE_YEAR_PERIOD_SQL = "INSERT INTO period (type, start, \"end\") VALUES ('EXPENSE_YEAR', ?, ?)";
private static final String SELECT_EXPENSE_YEAR_PERIOD_SQL = "SELECT id FROM period WHERE type = 'EXPENSE_YEAR'";
private static final String INSERT_LINK_TRANSACTION_PERIOD_SQL = "INSERT INTO link_transaction_period (transaction_id, period_id) VALUES (?, ?)";
private Timestamp firstPeriodStart;
private Timestamp lastPeriodEnd;
@Override
public void migrate(Context context) throws Exception {
final long expenseYearPeriodId = createExpenseYearPeriod(context);
if (expenseYearPeriodId == -1) {
return;
}
final List<TransactionAccountsContainer> cons = getContainers(context);
for (TransactionAccountsContainer con : cons) {
handleAccount(con, context, expenseYearPeriodId, true);
handleAccount(con, context, expenseYearPeriodId, false);
handleTransaction(con, context, expenseYearPeriodId);
}
}
private long createExpenseYearPeriod(Context context) throws Exception {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(SELECT_FIRST_PERIOD_SQL)) {
statement.setMaxRows(1);
final ResultSet rs = statement.executeQuery();
// The ResultSet contains only one entry as specified with the setMaxRows(1) call above
if (!rs.next()) {
return -1; // it may happen that no period for 2019 exists
}
firstPeriodStart = rs.getTimestamp(1);
try (PreparedStatement statementLast =
context
.getConnection()
.prepareStatement(SELECT_LAST_PERIOD_SQL)) {
statementLast.setMaxRows(1);
final ResultSet rsLast = statementLast.executeQuery();
// The ResultSet contains only one entry as specified with the setMaxRows(1) call above
rsLast.next();
lastPeriodEnd = rsLast.getTimestamp(1);
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_EXPENSE_YEAR_PERIOD_SQL)) {
statementInsert.setTimestamp(1, firstPeriodStart);
statementInsert.setTimestamp(2, lastPeriodEnd);
statementInsert.execute();
try (PreparedStatement statementSelect =
context
.getConnection()
.prepareStatement(SELECT_EXPENSE_YEAR_PERIOD_SQL)) {
final ResultSet rsSelect = statementSelect.executeQuery();
// We know it's there as we just created it
rsSelect.next();
return rsSelect.getLong(1);
}
}
}
}
}
private List<TransactionAccountsContainer> getContainers(Context context) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(GET_CONTAINERS_SQL)) {
statement.setDate(1, new Date(firstPeriodStart.toLocalDateTime().toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.DAYS).toEpochMilli()));
statement.setDate(2, new Date(lastPeriodEnd.toLocalDateTime().toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.DAYS).toEpochMilli()));
final ResultSet rs = statement.executeQuery();
final List<TransactionAccountsContainer> cons = new ArrayList<>();
while(rs.next()) {
cons.add(new TransactionAccountsContainer(
rs.getLong(1), // transaction amount
rs.getLong(2), // from account ID
rs.getLong(3), // to account ID
rs.getLong(4) // ID
));
}
return cons;
}
}
private void handleAccount(TransactionAccountsContainer con, Context context, long expenseYearPeriodId, boolean from) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(ACCOUNT_STATISTIC_EXISTS_SQL)) {
statement.setLong(1, from ? con.fromAccountId : con.toAccountId);
statement.setLong(2, expenseYearPeriodId);
final ResultSet rs = statement.executeQuery();
boolean found = false;
// Only one possible entry because of the unique constraint
while(rs.next()) {
found = true;
try (PreparedStatement statementUpdate =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_UPDATE_FROM_SQL : ACCOUNT_STATISTIC_UPDATE_TO_SQL)) {
statementUpdate.setLong(1, con.transactionAmount);
statementUpdate.setLong(2, rs.getLong(1));
statementUpdate.executeUpdate();
}
}
// We need to create a new account_statistic entry for the tuple {account_id;period_id}
if (!found) {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_INSERT_FROM_SQL : ACCOUNT_STATISTIC_INSERT_TO_SQL)) {
statementInsert.setLong(1, from ? con.fromAccountId : con.toAccountId);
statementInsert.setLong(2, expenseYearPeriodId);
statementInsert.setLong(3, con.transactionAmount);
statementInsert.execute();
}
}
}
}
private void handleTransaction(TransactionAccountsContainer con, Context context, long expenseYearPeriodId) throws SQLException {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_LINK_TRANSACTION_PERIOD_SQL)) {
statementInsert.setLong(1, con.id);
statementInsert.setLong(2, expenseYearPeriodId);
statementInsert.execute();
}
}
}

View File

@@ -0,0 +1,183 @@
package database.common;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.*;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalField;
import java.util.ArrayList;
import java.util.List;
public class V26_0_2__createExpenseYearPeriod_2020 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;
this.toAccountId = toAccountId;
this.id = id;
}
}
private static final String GET_CONTAINERS_SQL = "SELECT t.amount, t.from_account_id, t.to_account_id, t.id FROM \"transaction\" t INNER JOIN link_transaction_period ltp ON ltp.transaction_id = t.id INNER JOIN period p ON p.id = ltp.period_id WHERE p.type = 'EXPENSE' AND t.\"date\" >= ?";
private static final String ACCOUNT_STATISTIC_EXISTS_SQL = "SELECT id FROM account_statistic WHERE account_id = ? AND period_id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_FROM_SQL = "UPDATE account_statistic SET spending_total_from = spending_total_from + ?, transaction_count_from = transaction_count_from + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_UPDATE_TO_SQL = "UPDATE account_statistic SET spending_total_to = spending_total_to + ?, transaction_count_to = transaction_count_to + 1 WHERE id = ?";
private static final String ACCOUNT_STATISTIC_INSERT_FROM_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, ?, 1, 0, 0)";
private static final String ACCOUNT_STATISTIC_INSERT_TO_SQL = "INSERT INTO account_statistic(account_id, period_id, spending_total_from, transaction_count_from, spending_total_to, transaction_count_to) VALUES(?, ?, 0, 0, ?, 1)";
private static final String SELECT_FIRST_PERIOD_SQL = "SELECT start FROM period WHERE type = 'EXPENSE' AND start > '2020-01-01' ORDER BY start ASC";
private static final String INSERT_EXPENSE_YEAR_PERIOD_SQL = "INSERT INTO period (type, start) VALUES ('EXPENSE_YEAR', ?)";
private static final String SELECT_EXPENSE_YEAR_PERIOD_SQL = "SELECT id FROM period WHERE type = 'EXPENSE_YEAR' AND start BETWEEN '2020-01-01' AND '2020-12-31'";
private static final String INSERT_LINK_TRANSACTION_PERIOD_SQL = "INSERT INTO link_transaction_period (transaction_id, period_id) VALUES (?, ?)";
private Timestamp firstPeriodStart;
@Override
public void migrate(Context context) throws Exception {
final long expenseYearPeriodId = createExpenseYearPeriod(context);
if (expenseYearPeriodId == -1) {
return;
}
final List<TransactionAccountsContainer> cons = getContainers(context);
for (TransactionAccountsContainer con : cons) {
handleAccount(con, context, expenseYearPeriodId, true);
handleAccount(con, context, expenseYearPeriodId, false);
handleTransaction(con, context, expenseYearPeriodId);
}
}
private long createExpenseYearPeriod(Context context) throws Exception {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(SELECT_FIRST_PERIOD_SQL)) {
statement.setMaxRows(1);
final ResultSet rs = statement.executeQuery();
// The ResultSet contains only one entry as specified with the setMaxRows(1) call above
if (!rs.next()) {
return -1; // it may happen that no period for 2020 exists
}
firstPeriodStart = rs.getTimestamp(1);
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_EXPENSE_YEAR_PERIOD_SQL)) {
statementInsert.setTimestamp(1, firstPeriodStart);
statementInsert.execute();
try (PreparedStatement statementSelect =
context
.getConnection()
.prepareStatement(SELECT_EXPENSE_YEAR_PERIOD_SQL)) {
final ResultSet rsSelect = statementSelect.executeQuery();
// We know it's there as we just created it
rsSelect.next();
return rsSelect.getLong(1);
}
}
}
}
private List<TransactionAccountsContainer> getContainers(Context context) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(GET_CONTAINERS_SQL)) {
statement.setDate(1, new Date(firstPeriodStart.toLocalDateTime().toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.DAYS).toEpochMilli()));
final ResultSet rs = statement.executeQuery();
final List<TransactionAccountsContainer> cons = new ArrayList<>();
while(rs.next()) {
cons.add(new TransactionAccountsContainer(
rs.getLong(1), // transaction amount
rs.getLong(2), // from account ID
rs.getLong(3), // to account ID
rs.getLong(4) // ID
));
}
return cons;
}
}
private void handleAccount(TransactionAccountsContainer con, Context context, long expenseYearPeriodId, boolean from) throws SQLException {
try (PreparedStatement statement =
context
.getConnection()
.prepareStatement(ACCOUNT_STATISTIC_EXISTS_SQL)) {
statement.setLong(1, from ? con.fromAccountId : con.toAccountId);
statement.setLong(2, expenseYearPeriodId);
final ResultSet rs = statement.executeQuery();
boolean found = false;
// Only one possible entry because of the unique constraint
while(rs.next()) {
found = true;
try (PreparedStatement statementUpdate =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_UPDATE_FROM_SQL : ACCOUNT_STATISTIC_UPDATE_TO_SQL)) {
statementUpdate.setLong(1, con.transactionAmount);
statementUpdate.setLong(2, rs.getLong(1));
statementUpdate.executeUpdate();
}
}
// We need to create a new account_statistic entry for the tuple {account_id;period_id}
if (!found) {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(from ? ACCOUNT_STATISTIC_INSERT_FROM_SQL : ACCOUNT_STATISTIC_INSERT_TO_SQL)) {
statementInsert.setLong(1, from ? con.fromAccountId : con.toAccountId);
statementInsert.setLong(2, expenseYearPeriodId);
statementInsert.setLong(3, con.transactionAmount);
statementInsert.execute();
}
}
}
}
private void handleTransaction(TransactionAccountsContainer con, Context context, long expenseYearPeriodId) throws SQLException {
try (PreparedStatement statementInsert =
context
.getConnection()
.prepareStatement(INSERT_LINK_TRANSACTION_PERIOD_SQL)) {
statementInsert.setLong(1, con.id);
statementInsert.setLong(2, expenseYearPeriodId);
statementInsert.execute();
}
}
}

View File

@@ -7,11 +7,23 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Transactional(propagation = Propagation.REQUIRED)
public interface PeriodRepository extends CrudRepository<Period, Long> {
@Query("SELECT p FROM Period p WHERE p.type = :type AND p.end IS NULL")
Period findCurrentExpensePeriod(PeriodType type);
@Query("SELECT p FROM Period p WHERE p.type = :type")
Period findGrandTotalPeriod(PeriodType type);
@Query("SELECT p FROM Period p WHERE p.type = :type AND YEAR(p.start) = :year")
Period findExpenseYearPeriod(PeriodType type, Integer year);
@Query("SELECT p FROM Period p WHERE p.type = :type AND YEAR(p.start) = :year")
Iterable<Period> getAllPeriodsForYear(PeriodType type, Integer year);
@Query("SELECT p FROM Period p WHERE p.type = :type AND p.start <= :date AND p.end >= :date")
Period findPeriodForDate(PeriodType type, LocalDateTime date);
}

View File

@@ -15,7 +15,7 @@ public interface RecurringTransactionRepository extends CrudRepository<Recurring
Iterable<RecurringTransaction> findRecurringTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);
@Query("SELECT rt FROM RecurringTransaction rt WHERE rt.deleted = false AND (rt.lastOccurrence IS NULL OR rt.lastOccurrence >= :lastOccurrence)")
Iterable<RecurringTransaction> findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate lastOccurrence);
Iterable<RecurringTransaction> findAllActive(LocalDate lastOccurrence);
Iterable<RecurringTransaction> findByDeletedFalse();
}

View File

@@ -1,10 +1,7 @@
package de.financer.dba;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.model.Account;
import de.financer.model.AccountType;
import de.financer.model.Period;
import de.financer.model.Transaction;
import de.financer.model.*;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
@@ -15,13 +12,14 @@ import java.util.List;
@Transactional(propagation = Propagation.REQUIRED)
public interface TransactionRepository extends CrudRepository<Transaction, Long> {
@Query("SELECT t FROM Transaction t WHERE t.toAccount = :toAccount OR t.fromAccount = :fromAccount")
Iterable<Transaction> findTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);
@Query("SELECT SUM(t.amount) FROM Transaction t JOIN t.periods p JOIN t.toAccount a WHERE a.type IN :expenseTypes AND p = :period")
Long getExpensesForPeriod(Period period, AccountType... expenseTypes);
@Query("SELECT SUM(t.amount) FROM Transaction t JOIN t.periods p JOIN t.toAccount a JOIN t.fromAccount f WHERE a.type IN :expenseTypes AND f.type != :startType GROUP BY p ORDER BY p.start ASC")
List<Long> getExpensesForAllPeriods(AccountType startType, AccountType... expenseTypes);
@Query("SELECT SUM(t.amount) FROM Transaction t JOIN t.periods p JOIN t.toAccount a JOIN t.fromAccount f WHERE a.type IN :expenseTypes AND f.type != :startType AND p.type = :type GROUP BY p ORDER BY p.start ASC")
List<Long> getExpensesForAllPeriods(PeriodType type, AccountType startType, AccountType... expenseTypes);
// The HQL contains a hack because Hibernate can't resolve the alias of the CASE column in the GROUP BY clause
// That's why the generated alias is used directly in the HQL. It will break if the columns in the SELECT clause get reordered

View File

@@ -4,6 +4,7 @@ import de.financer.ResponseReason;
import de.financer.dba.PeriodRepository;
import de.financer.model.Period;
import de.financer.model.PeriodType;
import de.financer.model.Transaction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
@@ -11,7 +12,10 @@ import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
@Service
public class PeriodService {
@@ -24,16 +28,24 @@ public class PeriodService {
private AccountStatisticService accountStatisticService;
/**
* @return the currently open expense period
* @return the currently open {@link PeriodType#EXPENSE expense} period
*/
public Period getCurrentExpensePeriod() {
return this.periodRepository.findCurrentExpensePeriod(PeriodType.EXPENSE);
}
/**
* @return the {@link PeriodType#GRAND_TOTAL grand total} period
*/
public Period getGrandTotalPeriod() {
return this.periodRepository.findGrandTotalPeriod(PeriodType.GRAND_TOTAL);
}
/**
* This method closes the current expense period and opens a new one.
*
* @return {@link ResponseReason#OK} if the operation succeeded, {@link ResponseReason#OK}
* @return {@link ResponseReason#OK} if the operation succeeded, {@link ResponseReason#UNKNOWN_ERROR} if an
* unexpected exception occurred.
*/
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason closeCurrentExpensePeriod() {
@@ -51,6 +63,8 @@ public class PeriodService {
this.periodRepository.save(currentPeriod);
this.periodRepository.save(nextPeriod);
handleExpenseYearPeriod(currentPeriod, nextPeriod);
this.accountStatisticService.generateNullStatisticsForUnusedAccounts(currentPeriod);
response = ResponseReason.OK;
@@ -63,7 +77,59 @@ public class PeriodService {
return response;
}
private void handleExpenseYearPeriod(Period currentPeriod, Period nextPeriod) {
final Period expenseYearPeriod = this.periodRepository
.findExpenseYearPeriod(PeriodType.EXPENSE_YEAR, currentPeriod.getStart().getYear());
if (expenseYearPeriod.getStart().getYear() != nextPeriod.getStart().getYear()) {
expenseYearPeriod.setEnd(currentPeriod.getEnd());
final Period nextExpenseYearPeriod = new Period();
nextExpenseYearPeriod.setType(PeriodType.EXPENSE_YEAR);
nextExpenseYearPeriod.setStart(nextPeriod.getStart());
this.periodRepository.save(expenseYearPeriod);
this.periodRepository.save(nextExpenseYearPeriod);
}
}
/**
* @return the {@link PeriodType#EXPENSE_YEAR expense year} period for the given expense period
*/
public Period getExpenseYearPeriod(Period expensePeriod) {
return this.periodRepository
.findExpenseYearPeriod(PeriodType.EXPENSE_YEAR, expensePeriod.getStart().getYear());
}
public Iterable<Period> getAllExpensePeriodsForYear(Integer year) {
return this.periodRepository.getAllPeriodsForYear(PeriodType.EXPENSE, year);
}
/**
* <p>
* This method gets the {@link Period} for the date of the given transaction. It is intended to handle the edge case
* of <i>late booking</i> a transaction after the corresponding period has already been closed. So most of the time
* it returns the {@link PeriodService#getCurrentExpensePeriod() current expense period}.
* </p>
* <p>
* It is important to note this method also returns the current expense period for transactions which have a date in
* the future.
* </p>
*
* @param transaction to get the period for
*
* @return either the current expense period or one of the past
*/
public Period getExpensePeriodForTransaction(Transaction transaction) {
// Using LocalTime.now() here is kinda hacky. We know that we are only called from transaction creation.
Period period = this.periodRepository
.findPeriodForDate(PeriodType.EXPENSE, LocalDateTime.of(transaction.getDate(), LocalTime.now()));
if (period == null) {
period = this.getCurrentExpensePeriod();
}
return period;
}
}

View File

@@ -3,10 +3,7 @@ package de.financer.service;
import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.dba.RecurringTransactionRepository;
import de.financer.model.Account;
import de.financer.model.HolidayWeekendType;
import de.financer.model.IntervalType;
import de.financer.model.RecurringTransaction;
import de.financer.model.*;
import org.apache.commons.collections4.IterableUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
@@ -45,13 +42,15 @@ public class RecurringTransactionService {
@Autowired
private TransactionService transactionService;
@Autowired
private PeriodService periodService;
public Iterable<RecurringTransaction> getAll() {
return this.recurringTransactionRepository.findByDeletedFalse();
}
public Iterable<RecurringTransaction> getAllActive() {
return this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(LocalDate.now());
return this.recurringTransactionRepository.findAllActive(LocalDate.now());
}
public Iterable<RecurringTransaction> getAllForAccount(String accountKey) {
@@ -85,7 +84,7 @@ public class RecurringTransactionService {
// there would never be a reminder about them. On the actual due date the reminder is deferred because of the
// HWT and for later runs it's not grabbed because of the condition '...LastOccurrenceGreaterThanEqual(now)'
final Iterable<RecurringTransaction> allRecurringTransactions = this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(now.minusDays(7));
.findAllActive(now.minusDays(7));
LOGGER.debug(String.format("Found %s candidate recurring transactions. Checking which are due",
IterableUtils.size(allRecurringTransactions)));

View File

@@ -17,9 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.*;
@Service
public class TransactionService {
@@ -92,7 +90,7 @@ public class TransactionService {
try {
final Transaction transaction = buildTransaction(fromAccount, toAccount, amount, description, date, recurringTransaction);
transaction.setPeriods(Collections.singleton(this.periodService.getCurrentExpensePeriod()));
transaction.setPeriods(getRelevantPeriods(transaction));
fromAccount.setCurrentBalance(fromAccount.getCurrentBalance() + (this.ruleService
.getMultiplierFromAccount(fromAccount) * amount));
@@ -124,6 +122,17 @@ public class TransactionService {
return response;
}
private Set<Period> getRelevantPeriods(Transaction transaction) {
final Set<Period> periods = new HashSet<>();
final Period expensePeriod = this.periodService.getExpensePeriodForTransaction(transaction);
periods.add(expensePeriod);
periods.add(this.periodService.getExpenseYearPeriod(expensePeriod));
periods.add(this.periodService.getGrandTotalPeriod());
return periods;
}
/**
* This method builds the actual transaction object with the given values.
*
@@ -252,13 +261,14 @@ public class TransactionService {
}
/**
* This method gets all sums of expenses of all periods, ordered from oldest to newest.
* This method gets all sums of expenses of all expense periods, ordered from oldest to newest.
*
* @return the sums of expenses - may be empty
* @see TransactionService#getExpensesCurrentPeriod()
*/
public List<Long> getExpensesAllPeriods() {
return this.transactionRepository.getExpensesForAllPeriods(AccountType.START, AccountType.EXPENSE, AccountType.LIABILITY);
return this.transactionRepository
.getExpensesForAllPeriods(PeriodType.EXPENSE, AccountType.START, AccountType.EXPENSE, AccountType.LIABILITY);
}
/**

View File

@@ -54,7 +54,7 @@ public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest {
// Arrange
// Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-3)));
final LocalDate now = LocalDate.now();
@@ -74,7 +74,7 @@ public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest {
// Arrange
// Implicitly: ruleService.isWeekend().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(0)));
// Today is a holiday, but yesterday was not
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE);
@@ -96,7 +96,7 @@ public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest {
// Arrange
// Implicitly: ruleService.isHoliday().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(0)));
// Today is a weekend day, but yesterday was not
Mockito.when(this.ruleService.isWeekend(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE);
@@ -118,7 +118,7 @@ public class RecurringTransactionService_getAllDueToday_DAILY_NEXT_WORKDAYTest {
// Arrange
// Implicitly: ruleService.isHoliday().return(false) and ruleService.isWeekend().return(false)
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(1)));
final LocalDate now = LocalDate.now();

View File

@@ -44,7 +44,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
public void test_getAllDueToday_duePast_holiday() {
// Arrange
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-1)));
// Today is not a holiday but yesterday was
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.TRUE);
@@ -82,7 +82,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1);
// The transaction occurs on a friday
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-(now.getDayOfWeek().getValue() + 2))));
// First False for the dueToday check, 2x True for actual weekend, second False for Friday
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
@@ -116,7 +116,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1);
// The transaction occurs on a sunday
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-(now.getDayOfWeek().getValue()))));
// First False for the dueToday check, 2x True for actual weekend, second False for Friday
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
@@ -143,7 +143,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
final LocalDate monday = now.minusDays(now.getDayOfWeek().getValue() - 1);
// The transaction occurs on a saturday
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-(now.getDayOfWeek().getValue() + 1))));
// First False for the dueToday check, 2x True for actual weekend, second False for Friday
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
@@ -167,7 +167,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
// Arrange
final LocalDate now = LocalDate.of(2019, 5, 19); // A sunday
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(LocalDate.of(2019, 5, 18))));
// First False for the dueToday check, 2x True for actual weekend, second False for Friday
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
@@ -193,7 +193,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_NEXT_WORKDAYTest
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(recurringTransaction));
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
.thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE);

View File

@@ -43,7 +43,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAY
public void test_getAllDueToday_dueFuture_holiday() {
// Arrange
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(1)));
// Today is not a holiday but tomorrow is
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.FALSE, Boolean.TRUE);
@@ -71,7 +71,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAY
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(recurringTransaction));
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE);
Mockito.when(this.ruleService.isWeekend(Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE);
@@ -100,7 +100,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_PREVIOUS_WORKDAY
recurringTransaction.setIntervalType(IntervalType.MONTHLY);
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(recurringTransaction));
Mockito.when(this.ruleService.isWeekend(Mockito.any()))
.thenReturn(Boolean.FALSE, Boolean.TRUE, Boolean.TRUE, Boolean.FALSE);

View File

@@ -43,7 +43,7 @@ public class RecurringTransactionService_getAllDueToday_MONTHLY_SAME_DAYTest {
public void test_getAllDueToday_duePast_holiday() {
// Arrange
Mockito.when(this.recurringTransactionRepository
.findByDeletedFalseAndLastOccurrenceIsNullOrLastOccurrenceGreaterThanEqual(Mockito.any()))
.findAllActive(Mockito.any()))
.thenReturn(Collections.singletonList(createRecurringTransaction(-1)));
// Today is not a holiday but yesterday was
Mockito.when(this.ruleService.isHoliday(Mockito.any())).thenReturn(Boolean.FALSE, Boolean.TRUE);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,6 +1,13 @@
v25 -> v26:
- Close of the current expense period now creates null statistic entries for accounts that have not been used in
bookings in the period to close. This way the average spending better reflects the period average
- Introduce period type GRAND TOTAL, that denotes a continuous, cumulative period, starting with the inception of the
financer app, without an end
- Introduce period type EXPENSE YEAR, that acts as a group for all expense periods in a year. This may not be a
calendar year, if e.g. the EXPENSE periods do not start at the first of every month. This includes all periods that
start the in the year. This is a preparation for extended reports and year-based booking
- Transactions with a date in the past are now assigned to the proper EXPENSE and EXPENSE YEAR periods
- Add a fav icon
v24 -> v25:
- Add color column in account overview

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.account-details(${account.key})}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.account-overview}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.account-new}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.account-group-new}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.recurring-transaction-new}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.recurring-to-transaction-with-amount}" />

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{'financer.heading.recurring-transaction-list.' + ${subTitle}}"/>

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.chart-config-account-expenses-for-period}"/>

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.chart-config-account-group-expenses-for-period}"/>

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.chart-select}"/>

View File

@@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" th:href="@{/css/main.css}">
<link rel="shortcut icon" th:href="@{/favicon.ico}" />
</head>
<body>
<h1 th:text="#{financer.heading.transaction-new}" />