Refactored database schema for more flexible attachments and content, and added basis for account history.

This commit is contained in:
Andrew Lalis 2023-12-30 23:08:18 -05:00
parent aa90f98424
commit 00636debf3
30 changed files with 607 additions and 216 deletions

View File

@ -49,6 +49,11 @@
<artifactId>h2</artifactId>
<version>2.2.224</version>
</dependency>
<dependency>
<groupId>com.github.f4b6a3</groupId>
<artifactId>ulid-creator</artifactId>
<version>5.2.2</version>
</dependency>
</dependencies>
<build>

View File

@ -2,8 +2,9 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.data.FileUtil;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
import com.andrewlalis.perfin.view.BindingUtil;
import javafx.application.Platform;
@ -17,14 +18,15 @@ import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import java.util.stream.Collectors;
import static com.andrewlalis.perfin.PerfinApp.router;
@ -129,36 +131,21 @@ public class CreateTransactionController implements RouteSelectionListener {
);
alert.show();
} else {
LocalDateTime timestamp = parseTimestamp();
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
List<TransactionAttachment> attachments = selectedAttachmentFiles.stream()
.map(file -> {
String filename = file.getName();
String filetypeSuffix = filename.substring(filename.lastIndexOf('.'));
String mimeType = FileUtil.MIMETYPES.get(filetypeSuffix);
return new TransactionAttachment(filename, mimeType);
}).toList();
Transaction transaction = new Transaction(timestamp, amount, currency, description);
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
List<Path> attachments = selectedAttachmentFiles.stream().map(File::toPath).toList();
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
long txId = repo.insert(transaction, affectedAccounts);
repo.addAttachments(txId, attachments);
// Copy the actual attachment files to their new locations.
for (var attachment : repo.findAttachments(txId)) {
Path filePath = attachment.getPath();
Path dirPath = filePath.getParent();
Path originalFilePath = selectedAttachmentFiles.stream()
.filter(file -> file.getName().equals(attachment.getFilename()))
.findFirst().orElseThrow().toPath();
try {
Files.createDirectories(dirPath);
Files.copy(originalFilePath, filePath);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
repo.insert(
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
attachments
);
});
router.navigateBackAndClear();
}
@ -191,13 +178,11 @@ public class CreateTransactionController implements RouteSelectionListener {
});
}
private Map<Long, AccountEntry.Type> getSelectedAccounts() {
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
Map<Long, AccountEntry.Type> accountsMap = new HashMap<>();
if (debitAccount != null) accountsMap.put(debitAccount.getId(), AccountEntry.Type.DEBIT);
if (creditAccount != null) accountsMap.put(creditAccount.getId(), AccountEntry.Type.CREDIT);
return accountsMap;
private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts(
linkCreditAccountComboBox.getValue(),
linkDebitAccountComboBox.getValue()
);
}
private LocalDateTime parseTimestamp() {

View File

@ -3,7 +3,6 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.Profile;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@ -12,6 +11,9 @@ import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Stream;
@ -84,6 +86,7 @@ public class EditAccountController implements RouteSelectionListener {
AccountType type = accountTypeChoiceBox.getValue();
Currency currency = accountCurrencyComboBox.getValue();
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
List<Path> attachments = Collections.emptyList();
Alert confirm = new Alert(
Alert.AlertType.CONFIRMATION,
@ -92,14 +95,13 @@ public class EditAccountController implements RouteSelectionListener {
Optional<ButtonType> result = confirm.showAndWait();
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
if (success) {
Account newAccount = new Account(type, number, name, currency);
long id = accountRepo.insert(newAccount);
Account savedAccount = accountRepo.findById(id).orElseThrow();
balanceRepo.insert(new BalanceRecord(id, initialBalance, savedAccount.getCurrency()));
long id = accountRepo.insert(type, number, name, currency);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
// Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow();
router.getHistory().clear();
router.navigate("account", savedAccount);
router.navigate("account", newAccount);
}
} else {
System.out.println("Updating account " + account.getName());
@ -113,7 +115,7 @@ public class EditAccountController implements RouteSelectionListener {
router.navigate("account", updatedAccount);
}
} catch (Exception e) {
e.printStackTrace();
e.printStackTrace(System.err);
}
}

View File

@ -2,10 +2,10 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.data.CurrencyUtil;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.model.TransactionAttachment;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AttachmentPreview;
import javafx.application.Platform;
@ -36,7 +36,7 @@ public class TransactionViewController {
@FXML public VBox attachmentsContainer;
@FXML public HBox attachmentsHBox;
private final ObservableList<TransactionAttachment> attachmentsList = FXCollections.observableArrayList();
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
public void setTransaction(Transaction transaction) {
this.transaction = transaction;
@ -67,7 +67,7 @@ public class TransactionViewController {
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
List<TransactionAttachment> attachments = repo.findAttachments(transaction.getId());
List<Attachment> attachments = repo.findAttachments(transaction.getId());
Platform.runLater(() -> attachmentsList.setAll(attachments));
});
});

View File

@ -2,8 +2,19 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.AccountEntry;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
public interface AccountEntryRepository extends AutoCloseable {
long insert(
LocalDateTime timestamp,
long accountId,
long transactionId,
BigDecimal amount,
AccountEntry.Type type,
Currency currency
);
List<AccountEntry> findAllByAccountId(long accountId);
}

View File

@ -0,0 +1,9 @@
package com.andrewlalis.perfin.data;
import java.time.LocalDateTime;
public interface AccountHistoryItemRepository extends AutoCloseable {
void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
void recordText(LocalDateTime timestamp, long accountId, String text);
}

View File

@ -3,6 +3,7 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import java.math.BigDecimal;
import java.time.Clock;
@ -13,7 +14,7 @@ import java.util.Optional;
import java.util.Set;
public interface AccountRepository extends AutoCloseable {
long insert(Account account);
long insert(AccountType type, String accountNumber, String name, Currency currency);
Page<Account> findAll(PageRequest pagination);
List<Account> findAllByCurrency(Currency currency);
Optional<Account> findById(long id);

View File

@ -0,0 +1,13 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.Attachment;
import java.nio.file.Path;
import java.util.Optional;
public interface AttachmentRepository extends AutoCloseable {
Attachment insert(Path sourcePath);
Optional<Attachment> findById(long attachmentId);
Optional<Attachment> findByIdentifier(String identifier);
void deleteById(long attachmentId);
}

View File

@ -2,7 +2,13 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.BalanceRecord;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
public interface BalanceRecordRepository extends AutoCloseable {
long insert(BalanceRecord record);
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId);
}

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.model.Account;
import javafx.application.Platform;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
@ -12,6 +13,8 @@ import java.util.Map;
import java.util.function.Consumer;
public interface DataSource {
Path getContentDir();
AccountRepository getAccountRepository();
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
DbUtil.useClosable(this::getAccountRepository, repoConsumer);
@ -27,6 +30,13 @@ public interface DataSource {
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
}
AttachmentRepository getAttachmentRepository();
default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) {
DbUtil.useClosable(this::getAttachmentRepository, repoConsumer);
}
AccountHistoryItemRepository getAccountHistoryItemRepository();
// Utility methods:
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {

View File

@ -17,4 +17,16 @@ public class DateUtil {
.atZoneSameInstant(ZoneId.systemDefault())
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
}
public static LocalDateTime localToUTC(LocalDateTime localTime, ZoneId localZone) {
return localTime.atZone(localZone).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
}
public static LocalDateTime localToUTC(LocalDateTime localTime) {
return localToUTC(localTime, ZoneId.systemDefault());
}
public static LocalDateTime nowAsUTC() {
return LocalDateTime.now(ZoneOffset.UTC);
}
}

View File

@ -27,6 +27,10 @@ public final class DbUtil {
}
}
public static void setArgs(PreparedStatement stmt, Object... args) {
setArgs(stmt, List.of(args));
}
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);

View File

@ -44,4 +44,14 @@ public class FileUtil {
}
});
}
public static String getTypeSuffix(String filename) {
int lastDotIdx = filename.lastIndexOf('.');
if (lastDotIdx == -1) return "";
return filename.substring(lastDotIdx);
}
public static String getTypeSuffix(Path filePath) {
return getTypeSuffix(filePath.getFileName().toString());
}
}

View File

@ -2,20 +2,30 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Transaction;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Map;
import java.util.Set;
public interface TransactionRepository extends AutoCloseable {
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
long insert(
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
List<Path> attachments
);
Page<Transaction> findAll(PageRequest pagination);
long countAll();
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<TransactionAttachment> findAttachments(long transactionId);
List<Attachment> findAttachments(long transactionId);
void delete(long transactionId);
}

View File

@ -1,16 +1,41 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
@Override
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
long entryId = DbUtil.insertOne(
conn,
"""
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
VALUES (?, ?, ?, ?, ?, ?)""",
List.of(
DbUtil.timestampFromUtcLDT(timestamp),
accountId,
transactionId,
amount,
type.name(),
currency.getCurrencyCode()
)
);
// Insert an entry into the account's history.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordAccountEntry(timestamp, accountId, entryId);
return entryId;
}
@Override
public List<AccountEntry> findAllByAccountId(long accountId) {
return DbUtil.findAll(

View File

@ -0,0 +1,58 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.List;
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
@Override
public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
List.of(itemId, entryId)
);
}
@Override
public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
List.of(itemId, recordId)
);
}
@Override
public void recordText(LocalDateTime timestamp, long accountId, String text) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_account_entry (item_id, description) VALUES (?, ?)",
List.of(itemId, text)
);
}
@Override
public void close() throws Exception {
conn.close();
}
private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
return DbUtil.insertOne(
conn,
"INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
List.of(
DbUtil.timestampFromUtcLDT(timestamp),
accountId,
type.name()
)
);
}
}

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
@ -19,18 +20,24 @@ import java.util.*;
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
@Override
public long insert(Account account) {
return DbUtil.insertOne(
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
return DbUtil.doTransaction(conn, () -> {
long accountId = DbUtil.insertOne(
conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
List.of(
DbUtil.timestampFromUtcNow(),
account.getType().name(),
account.getAccountNumber(),
account.getName(),
account.getCurrency().getCurrencyCode()
type.name(),
accountNumber,
name,
currency.getCurrencyCode()
)
);
// Insert a history item indicating the creation of the account.
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
return accountId;
});
}
@Override

View File

@ -0,0 +1,84 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.FileUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.github.f4b6a3.ulid.UlidCreator;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
public record JdbcAttachmentRepository(Connection conn, Path contentDir) implements AttachmentRepository {
@Override
public Attachment insert(Path sourcePath) {
String filename = sourcePath.getFileName().toString();
String filetypeSuffix = FileUtil.getTypeSuffix(filename).toLowerCase();
String contentType = FileUtil.MIMETYPES.getOrDefault(filetypeSuffix, "text/plain");
Timestamp timestamp = DbUtil.timestampFromUtcNow();
String identifier = UlidCreator.getUlid().toString();
long id = DbUtil.insertOne(
conn,
"INSERT INTO attachment (uploaded_at, identifier, filename, content_type) VALUES (?, ?, ?, ?)",
List.of(timestamp, identifier, filename, contentType)
);
Attachment attachment = new Attachment(id, DbUtil.utcLDTFromTimestamp(timestamp), identifier, filename, contentType);
// Save the file to the content directory.
Path storageFilePath = attachment.getPath(contentDir);
try {
Files.createDirectories(storageFilePath.getParent());
Files.copy(sourcePath, storageFilePath);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return attachment;
}
@Override
public Optional<Attachment> findById(long attachmentId) {
return DbUtil.findOne(conn, "SELECT * FROM attachment WHERE id = ?", List.of(attachmentId), JdbcAttachmentRepository::parseAttachment);
}
@Override
public Optional<Attachment> findByIdentifier(String identifier) {
return DbUtil.findOne(conn, "SELECT * FROM attachment WHERE identifier = ?", List.of(identifier), JdbcAttachmentRepository::parseAttachment);
}
@Override
public void deleteById(long attachmentId) {
// First get it and try to delete the stored file.
var optionalAttachment = findById(attachmentId);
if (optionalAttachment.isPresent()) {
try {
Files.delete(optionalAttachment.get().getPath(contentDir));
} catch (IOException e) {
e.printStackTrace(System.err);
// TODO: Add some sort of persistent error logging.
}
DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId));
}
}
@Override
public void close() throws Exception {
conn.close();
}
public static Attachment parseAttachment(ResultSet rs) throws SQLException {
return new Attachment(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("uploaded_at")),
rs.getString("identifier"),
rs.getString("filename"),
rs.getString("content_type")
);
}
}

View File

@ -1,28 +1,44 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRecordRepository {
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
@Override
public long insert(BalanceRecord record) {
return DbUtil.insertOne(
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
return DbUtil.doTransaction(conn, () -> {
long recordId = DbUtil.insertOne(
conn,
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
List.of(
DbUtil.timestampFromUtcNow(),
record.getAccountId(),
record.getBalance(),
record.getCurrency().getCurrencyCode()
)
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency)
);
// Insert attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
try (var stmt = conn.prepareStatement("INSERT INTO balance_record_attachment(balance_record_id, attachment_id) VALUES (?, ?)")) {
for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
DbUtil.setArgs(stmt, recordId, attachment.getId());
stmt.executeUpdate();
}
}
// Add a history item entry.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
return recordId;
});
}
@Override

View File

@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.*;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
@ -12,9 +13,11 @@ import java.sql.SQLException;
*/
public class JdbcDataSource implements DataSource {
private final String jdbcUrl;
private final Path contentDir;
public JdbcDataSource(String jdbcUrl) {
public JdbcDataSource(String jdbcUrl, Path contentDir) {
this.jdbcUrl = jdbcUrl;
this.contentDir = contentDir;
}
public Connection getConnection() {
@ -25,6 +28,11 @@ public class JdbcDataSource implements DataSource {
}
}
@Override
public Path getContentDir() {
return contentDir;
}
@Override
public AccountRepository getAccountRepository() {
return new JdbcAccountRepository(getConnection());
@ -32,11 +40,21 @@ public class JdbcDataSource implements DataSource {
@Override
public BalanceRecordRepository getBalanceRecordRepository() {
return new JdbcBalanceRecordRepository(getConnection());
return new JdbcBalanceRecordRepository(getConnection(), contentDir);
}
@Override
public TransactionRepository getTransactionRepository() {
return new JdbcTransactionRepository(getConnection());
return new JdbcTransactionRepository(getConnection(), contentDir);
}
@Override
public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir);
}
@Override
public AccountHistoryItemRepository getAccountHistoryItemRepository() {
return new JdbcAccountHistoryItemRepository(getConnection());
}
}

View File

@ -1,84 +1,58 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.*;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.*;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Currency;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
public record JdbcTransactionRepository(Connection conn, Path contentDir) implements TransactionRepository {
@Override
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
public long insert(
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
List<Path> attachments
) {
return DbUtil.doTransaction(conn, () -> {
long txId = insertTransaction(timestamp, transaction);
insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap);
return txId;
});
}
@Override
public void addAttachments(long transactionId, List<TransactionAttachment> attachments) {
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
DbUtil.doTransaction(conn, () -> {
for (var attachment : attachments) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_attachment (uploaded_at, transaction_id, filename, content_type) VALUES (?, ?, ?, ?)",
List.of(
timestamp,
transactionId,
attachment.getFilename(),
attachment.getContentType()
)
);
}
});
}
private long insertTransaction(Timestamp timestamp, Transaction transaction) {
return DbUtil.insertOne(
// 1. Insert the transaction.
long txId = DbUtil.insertOne(
conn,
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
List.of(
timestamp,
transaction.getAmount(),
transaction.getCurrency().getCurrencyCode(),
transaction.getDescription()
)
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
);
}
private void insertAccountEntriesForTransaction(
Timestamp timestamp,
long txId,
Transaction transaction,
Map<Long, AccountEntry.Type> accountsMap
) throws SQLException {
try (var stmt = conn.prepareStatement(
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
)) {
for (var entry : accountsMap.entrySet()) {
long accountId = entry.getKey();
AccountEntry.Type entryType = entry.getValue();
DbUtil.setArgs(stmt, List.of(
timestamp,
accountId,
txId,
transaction.getAmount(),
entryType.name(),
transaction.getCurrency().getCurrencyCode()
));
// 2. Insert linked account entries.
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.DEBIT, currency));
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
// Insert the link-table entry.
DbUtil.setArgs(stmt, txId, attachment.getId());
stmt.executeUpdate();
}
}
return txId;
});
}
@Override
@ -87,7 +61,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
conn,
"SELECT * FROM transaction",
pagination,
JdbcTransactionRepository::parse
JdbcTransactionRepository::parseTransaction
);
}
@ -105,28 +79,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id
WHERE account_entry.account_id IN (%s)
""", idsStr);
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parse);
}
@Override
public Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId) {
List<AccountEntry> entries = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE transaction_id = ?",
List.of(transactionId),
JdbcAccountEntryRepository::parse
);
Map<AccountEntry, Account> map = new HashMap<>();
for (var entry : entries) {
Account account = DbUtil.findOne(
conn,
"SELECT * FROM account WHERE id = ?",
List.of(entry.getAccountId()),
JdbcAccountRepository::parseAccount
).orElseThrow();
map.put(entry, account);
}
return map;
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
}
@Override
@ -157,12 +110,17 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
}
@Override
public List<TransactionAttachment> findAttachments(long transactionId) {
public List<Attachment> findAttachments(long transactionId) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_attachment WHERE transaction_id = ? ORDER BY filename ASC",
"""
SELECT *
FROM attachment
LEFT JOIN transaction_attachment ta ON ta.attachment_id = attachment.id
WHERE ta.transaction_id = ?
ORDER BY uploaded_at ASC, filename ASC""",
List.of(transactionId),
JdbcTransactionRepository::parseAttachment
JdbcAttachmentRepository::parseAttachment
);
}
@ -176,7 +134,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
conn.close();
}
public static Transaction parse(ResultSet rs) throws SQLException {
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
@ -185,14 +143,4 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
rs.getString("description")
);
}
public static TransactionAttachment parseAttachment(ResultSet rs) throws SQLException {
return new TransactionAttachment(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("uploaded_at")),
rs.getLong("transaction_id"),
rs.getString("filename"),
rs.getString("content_type")
);
}
}

View File

@ -0,0 +1,60 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.data.FileUtil;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* An attachment is a file uploaded so that it may be related to some other
* entity, like a receipt attached to a transaction, or a bank statement to an
* account balance record.
*/
public class Attachment {
private long id;
private LocalDateTime timestamp;
private String identifier;
private String filename;
private String contentType;
public Attachment(long id, LocalDateTime timestamp, String identifier, String filename, String contentType) {
this.id = id;
this.timestamp = timestamp;
this.identifier = identifier;
this.filename = filename;
this.contentType = contentType;
}
public Attachment(LocalDateTime timestamp, String identifier, String filename, String contentType) {
this.timestamp = timestamp;
this.identifier = identifier;
this.filename = filename;
this.contentType = contentType;
}
public long getId() {
return id;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public String getIdentifier() {
return identifier;
}
public String getFilename() {
return filename;
}
public String getContentType() {
return contentType;
}
public Path getPath(Path contentDir) {
return contentDir.resolve(timestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.resolve(identifier + FileUtil.getTypeSuffix(filename).toLowerCase());
}
}

View File

@ -148,7 +148,7 @@ public class Profile {
String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
boolean exists = Files.exists(getDatabaseFile(name));
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl);
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl, getContentDir(name));
if (!exists) {// Initialize the datasource using schema.sql.
try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
if (in == null) throw new IOException("Could not load /sql/schema.sql");

View File

@ -9,6 +9,7 @@ import java.time.LocalDateTime;
* A file that's been attached to a transaction as additional context for it,
* like a receipt or invoice copy.
*/
@Deprecated
public class TransactionAttachment {
private long id;
private LocalDateTime uploadedAt;

View File

@ -0,0 +1,45 @@
package com.andrewlalis.perfin.model.history;
import java.time.LocalDateTime;
/**
* The base class representing account history items, a read-only record of an
* account's data and changes over time. The type of history item determines
* what exactly it means, and could be something like an account entry, balance
* record, or modifications to the account's properties.
*/
public class AccountHistoryItem {
private long id;
private LocalDateTime timestamp;
private long accountId;
private AccountHistoryItemType type;
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
this.id = id;
this.timestamp = timestamp;
this.accountId = accountId;
this.type = type;
}
public AccountHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
this.timestamp = timestamp;
this.accountId = accountId;
this.type = type;
}
public long getId() {
return id;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public long getAccountId() {
return accountId;
}
public AccountHistoryItemType getType() {
return type;
}
}

View File

@ -0,0 +1,7 @@
package com.andrewlalis.perfin.model.history;
public enum AccountHistoryItemType {
TEXT,
ACCOUNT_ENTRY,
BALANCE_RECORD
}

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.model.TransactionAttachment;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.Profile;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
@ -20,7 +21,7 @@ public class AttachmentPreview extends BorderPane {
public static final double LABEL_SIZE = 18.0;
public static final double HEIGHT = IMAGE_SIZE + LABEL_SIZE;
public AttachmentPreview(TransactionAttachment attachment) {
public AttachmentPreview(Attachment attachment) {
BorderPane contentContainer = new BorderPane();
Label nameLabel = new Label(attachment.getFilename());
nameLabel.setStyle("-fx-font-size: small;");
@ -33,7 +34,7 @@ public class AttachmentPreview extends BorderPane {
boolean showDocIcon = true;
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
if (imageTypes.contains(attachment.getContentType())) {
try (var in = Files.newInputStream(attachment.getPath())) {
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
contentContainer.setCenter(new ImageView(img));
showDocIcon = false;
@ -43,6 +44,7 @@ public class AttachmentPreview extends BorderPane {
}
if (showDocIcon) {
try (var in = AttachmentPreview.class.getResourceAsStream("/images/doc-icon.png")) {
if (in == null) throw new NullPointerException("Missing /images/doc-icon.png resource.");
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
contentContainer.setCenter(new ImageView(img));
} catch (IOException e) {

View File

@ -9,6 +9,8 @@ module com.andrewlalis.perfin {
requires java.sql;
requires com.github.f4b6a3.ulid;
exports com.andrewlalis.perfin to javafx.graphics;
exports com.andrewlalis.perfin.view to javafx.graphics;
exports com.andrewlalis.perfin.model to javafx.graphics;

View File

@ -16,6 +16,14 @@ CREATE TABLE transaction (
description VARCHAR(255) NULL
);
CREATE TABLE attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL,
identifier VARCHAR(63) NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(255) NOT NULL
);
CREATE TABLE account_entry (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL,
@ -33,16 +41,15 @@ CREATE TABLE account_entry (
);
CREATE TABLE transaction_attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL,
transaction_id BIGINT NOT NULL,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(255) NOT NULL,
attachment_id BIGINT NOT NULL,
PRIMARY KEY (transaction_id, attachment_id),
CONSTRAINT fk_transaction_attachment_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT uq_transaction_attachment_filename
UNIQUE(transaction_id, filename)
CONSTRAINT fk_transaction_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE balance_record (
@ -55,3 +62,55 @@ CREATE TABLE balance_record (
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE balance_record_attachment (
balance_record_id BIGINT NOT NULL,
attachment_id BIGINT NOT NULL,
PRIMARY KEY (balance_record_id, attachment_id),
CONSTRAINT fk_balance_record_attachment_balance_record
FOREIGN KEY (balance_record_id) REFERENCES balance_record(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_balance_record_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_history_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL,
account_id BIGINT NOT NULL,
type VARCHAR(63) NOT NULL,
CONSTRAINT fk_account_history_item_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_history_item_text (
item_id BIGINT NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
CONSTRAINT fk_account_history_item_text_pk
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_history_item_account_entry (
item_id BIGINT NOT NULL PRIMARY KEY,
entry_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_account_entry_pk
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_account_entry
FOREIGN KEY (entry_id) REFERENCES account_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_history_item_balance_record (
item_id BIGINT NOT NULL PRIMARY KEY,
record_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_balance_record_pk
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_balance_record
FOREIGN KEY (record_id) REFERENCES balance_record(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -4,25 +4,6 @@ It contains all the files and other large content that you've added to your
profile, including but not limited to transaction attachments (receipts,
invoices, etc.), bank statements, or portfolio exports. These files are usually
managed by the Perfin app through in-app actions, but you're also welcome to
browse them directly, or even delete files you no longer want stored.
Here's an overview of where you can find everything:
- transaction-attachments/
This folder contains all files you've attached to transactions you've created.
Within this folder, you'll see a series of sub-folders organized by the date
at which attachments were uploaded, and in side each date folder, you'll find
one folder for each transaction. For example, your folder might look like this:
my-profile/
content/
transaction-attachments/
2023-12-28/
tx-2/
receipt.png
tx-3/
invoice.pdf
2024-01-04/
tx-4/
receipt.jpeg
browse them directly, or even delete files you no longer want stored. The app
will gracefully accept that they've been removed, and should carry on
regardless.