From 00636debf3c7f6b79367103ba67a271e06f30ee5 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 30 Dec 2023 23:08:18 -0500 Subject: [PATCH] Refactored database schema for more flexible attachments and content, and added basis for account history. --- pom.xml | 5 + .../control/CreateTransactionController.java | 61 +++---- .../perfin/control/EditAccountController.java | 16 +- .../control/TransactionViewController.java | 6 +- .../perfin/data/AccountEntryRepository.java | 11 ++ .../data/AccountHistoryItemRepository.java | 9 ++ .../perfin/data/AccountRepository.java | 3 +- .../perfin/data/AttachmentRepository.java | 13 ++ .../perfin/data/BalanceRecordRepository.java | 8 +- .../andrewlalis/perfin/data/DataSource.java | 10 ++ .../com/andrewlalis/perfin/data/DateUtil.java | 12 ++ .../com/andrewlalis/perfin/data/DbUtil.java | 4 + .../com/andrewlalis/perfin/data/FileUtil.java | 10 ++ .../perfin/data/TransactionRepository.java | 22 ++- .../data/impl/JdbcAccountEntryRepository.java | 25 +++ .../JdbcAccountHistoryItemRepository.java | 58 +++++++ .../data/impl/JdbcAccountRepository.java | 31 ++-- .../data/impl/JdbcAttachmentRepository.java | 84 ++++++++++ .../impl/JdbcBalanceRecordRepository.java | 40 +++-- .../perfin/data/impl/JdbcDataSource.java | 24 ++- .../data/impl/JdbcTransactionRepository.java | 150 ++++++------------ .../andrewlalis/perfin/model/Attachment.java | 60 +++++++ .../com/andrewlalis/perfin/model/Profile.java | 2 +- .../perfin/model/TransactionAttachment.java | 1 + .../model/history/AccountHistoryItem.java | 45 ++++++ .../model/history/AccountHistoryItemType.java | 7 + .../view/component/AttachmentPreview.java | 8 +- src/main/java/module-info.java | 2 + src/main/resources/sql/schema.sql | 71 ++++++++- src/main/resources/text/contentDirReadme.txt | 25 +-- 30 files changed, 607 insertions(+), 216 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/AttachmentRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/Attachment.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java diff --git a/pom.xml b/pom.xml index 44a2705..dc999e1 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,11 @@ h2 2.2.224 + + com.github.f4b6a3 + ulid-creator + 5.2.2 + diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java index 5e27e9a..8056ee5 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -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 affectedAccounts = getSelectedAccounts(); - List 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 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 getSelectedAccounts() { - Account debitAccount = linkDebitAccountComboBox.getValue(); - Account creditAccount = linkCreditAccountComboBox.getValue(); - Map 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() { diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index eff6b4b..145eb23 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -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 attachments = Collections.emptyList(); Alert confirm = new Alert( Alert.AlertType.CONFIRMATION, @@ -92,14 +95,13 @@ public class EditAccountController implements RouteSelectionListener { Optional 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); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index bde5bfd..fdc3f17 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -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 attachmentsList = FXCollections.observableArrayList(); + private final ObservableList 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 attachments = repo.findAttachments(transaction.getId()); + List attachments = repo.findAttachments(transaction.getId()); Platform.runLater(() -> attachmentsList.setAll(attachments)); }); }); diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java index 18fc8d9..8472b16 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java @@ -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 findAllByAccountId(long accountId); } diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java new file mode 100644 index 0000000..0413e07 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java @@ -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); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java index 2c25beb..fd19b8e 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java @@ -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 findAll(PageRequest pagination); List findAllByCurrency(Currency currency); Optional findById(long id); diff --git a/src/main/java/com/andrewlalis/perfin/data/AttachmentRepository.java b/src/main/java/com/andrewlalis/perfin/data/AttachmentRepository.java new file mode 100644 index 0000000..69cc2b4 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/AttachmentRepository.java @@ -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 findById(long attachmentId); + Optional findByIdentifier(String identifier); + void deleteById(long attachmentId); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java index 19d73ca..82db27a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java @@ -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 attachments); BalanceRecord findLatestByAccountId(long accountId); } diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index 0447835..aa67bcd 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -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 repoConsumer) { DbUtil.useClosable(this::getAccountRepository, repoConsumer); @@ -27,6 +30,13 @@ public interface DataSource { DbUtil.useClosable(this::getTransactionRepository, repoConsumer); } + AttachmentRepository getAttachmentRepository(); + default void useAttachmentRepository(ThrowableConsumer repoConsumer) { + DbUtil.useClosable(this::getAttachmentRepository, repoConsumer); + } + + AccountHistoryItemRepository getAccountHistoryItemRepository(); + // Utility methods: default void getAccountBalanceText(Account account, Consumer balanceConsumer) { diff --git a/src/main/java/com/andrewlalis/perfin/data/DateUtil.java b/src/main/java/com/andrewlalis/perfin/data/DateUtil.java index 60b7e2b..245764a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DateUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/DateUtil.java @@ -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); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java index 402d571..e58a634 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java @@ -27,6 +27,10 @@ public final class DbUtil { } } + public static void setArgs(PreparedStatement stmt, Object... args) { + setArgs(stmt, List.of(args)); + } + public static List findAll(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); diff --git a/src/main/java/com/andrewlalis/perfin/data/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java index c5b36ab..c6760e4 100644 --- a/src/main/java/com/andrewlalis/perfin/data/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java @@ -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()); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 43e07d1..4567318 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -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 accountsMap); - void addAttachments(long transactionId, List attachments); + long insert( + LocalDateTime utcTimestamp, + BigDecimal amount, + Currency currency, + String description, + CreditAndDebitAccounts linkedAccounts, + List attachments + ); Page findAll(PageRequest pagination); long countAll(); Page findAllByAccounts(Set accountIds, PageRequest pagination); - Map findEntriesWithAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId); - List findAttachments(long transactionId); + List findAttachments(long transactionId); void delete(long transactionId); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java index 3476c06..3aecf97 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -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 findAllByAccountId(long accountId) { return DbUtil.findAll( diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java new file mode 100644 index 0000000..de4815b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java @@ -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() + ) + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 86d6a94..aeff40f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -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( - 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() - ) - ); + 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(), + 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 diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java new file mode 100644 index 0000000..cd21288 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java @@ -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 findById(long attachmentId) { + return DbUtil.findOne(conn, "SELECT * FROM attachment WHERE id = ?", List.of(attachmentId), JdbcAttachmentRepository::parseAttachment); + } + + @Override + public Optional 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") + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java index 6a782f5..85693bb 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -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( - conn, - "INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)", - List.of( - DbUtil.timestampFromUtcNow(), - record.getAccountId(), - record.getBalance(), - record.getCurrency().getCurrencyCode() - ) - ); + public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List attachments) { + return DbUtil.doTransaction(conn, () -> { + long recordId = DbUtil.insertOne( + conn, + "INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)", + 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 diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index d20006c..afedbef 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -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()); } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index a5f812e..bcd3e84 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -1,93 +1,67 @@ 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 accountsMap) { - final Timestamp timestamp = DbUtil.timestampFromUtcNow(); + public long insert( + LocalDateTime utcTimestamp, + BigDecimal amount, + Currency currency, + String description, + CreditAndDebitAccounts linkedAccounts, + List attachments + ) { return DbUtil.doTransaction(conn, () -> { - long txId = insertTransaction(timestamp, transaction); - insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap); + // 1. Insert the transaction. + long txId = DbUtil.insertOne( + conn, + "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", + List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) + ); + // 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 - public void addAttachments(long transactionId, List 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( - conn, - "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", - List.of( - timestamp, - transaction.getAmount(), - transaction.getCurrency().getCurrencyCode(), - transaction.getDescription() - ) - ); - } - - private void insertAccountEntriesForTransaction( - Timestamp timestamp, - long txId, - Transaction transaction, - Map 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() - )); - stmt.executeUpdate(); - } - } - } - @Override public Page findAll(PageRequest pagination) { return DbUtil.findAll( 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 findEntriesWithAccounts(long transactionId) { - List entries = DbUtil.findAll( - conn, - "SELECT * FROM account_entry WHERE transaction_id = ?", - List.of(transactionId), - JdbcAccountEntryRepository::parse - ); - Map 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 findAttachments(long transactionId) { + public List 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") - ); - } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Attachment.java b/src/main/java/com/andrewlalis/perfin/model/Attachment.java new file mode 100644 index 0000000..01082b6 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/Attachment.java @@ -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()); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 1ead66f..83e9ad7 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -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"); diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java b/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java index 4073698..e5206b0 100644 --- a/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java @@ -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; diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java new file mode 100644 index 0000000..32a4fbb --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java new file mode 100644 index 0000000..eeeac1d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java @@ -0,0 +1,7 @@ +package com.andrewlalis.perfin.model.history; + +public enum AccountHistoryItemType { + TEXT, + ACCOUNT_ENTRY, + BALANCE_RECORD +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java index e6e87c8..f211dce 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java @@ -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 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) { diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index f70e4dc..4ceaa78 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -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; diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index e89fde0..71010d6 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -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 +); diff --git a/src/main/resources/text/contentDirReadme.txt b/src/main/resources/text/contentDirReadme.txt index 8034aeb..18fa032 100644 --- a/src/main/resources/text/contentDirReadme.txt +++ b/src/main/resources/text/contentDirReadme.txt @@ -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.