diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java index 3ca4b07..5e27e9a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -2,16 +2,25 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.DateUtil; -import com.andrewlalis.perfin.model.Account; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.data.FileUtil; +import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.view.AccountComboBoxCellFactory; +import com.andrewlalis.perfin.view.BindingUtil; import javafx.application.Platform; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.*; +import javafx.scene.layout.AnchorPane; +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; @@ -34,6 +43,10 @@ public class CreateTransactionController implements RouteSelectionListener { @FXML public ComboBox linkCreditAccountComboBox; @FXML public Label linkedAccountsErrorLabel; + private final ObservableList selectedAttachmentFiles = FXCollections.observableArrayList(); + @FXML public VBox selectedFilesVBox; + @FXML public Label noSelectedFilesLabel; + @FXML public void initialize() { // Setup error field validation. timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty()); @@ -67,6 +80,41 @@ public class CreateTransactionController implements RouteSelectionListener { currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> { updateLinkAccountComboBoxes(newValue); }); + + // Show the "no files selected" label when the list is empty. And sync the vbox with the selected files. + noSelectedFilesLabel.managedProperty().bind(noSelectedFilesLabel.visibleProperty()); + var filesListProp = new SimpleListProperty<>(selectedAttachmentFiles); + noSelectedFilesLabel.visibleProperty().bind(filesListProp.emptyProperty()); + BindingUtil.mapContent(selectedFilesVBox.getChildren(), selectedAttachmentFiles, file -> { + Label filenameLabel = new Label(file.getName()); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> { + selectedAttachmentFiles.remove(file); + }); + AnchorPane fileBox = new AnchorPane(filenameLabel, removeButton); + AnchorPane.setLeftAnchor(filenameLabel, 0.0); + AnchorPane.setRightAnchor(removeButton, 0.0); + return fileBox; + }); + } + + @FXML public void selectAttachmentFile() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select Transaction Attachment(s)"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter( + "Attachment Files", + "*.pdf", "*.docx", "*.odt", "*.html", "*.txt", "*.md", "*.xml", "*.json", + "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.bmp", "*.tiff" + ) + ); + List files = fileChooser.showOpenMultipleDialog(amountField.getScene().getWindow()); + if (files == null) return; + for (var file : files) { + if (selectedAttachmentFiles.stream().noneMatch(f -> !f.equals(file) && f.getName().equals(file.getName()))) { + selectedAttachmentFiles.add(file); + } + } } @FXML public void save() { @@ -86,9 +134,31 @@ public class CreateTransactionController implements RouteSelectionListener { 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); Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { - repo.insert(transaction, affectedAccounts); + 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); + } + } }); router.navigateBackAndClear(); } @@ -107,6 +177,7 @@ public class CreateTransactionController implements RouteSelectionListener { timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); amountField.setText("0"); descriptionField.setText(null); + selectedAttachmentFiles.clear(); Thread.ofVirtual().start(() -> { Profile.getCurrent().getDataSource().useAccountRepository(repo -> { var currencies = repo.findAllUsedCurrencies().stream() diff --git a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java index 8b63560..402d571 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java @@ -125,10 +125,10 @@ public final class DbUtil { } } - public static void doTransaction(Connection conn, SQLRunnable runnable) { + public static T doTransaction(Connection conn, SQLSupplier supplier) { try { conn.setAutoCommit(false); - runnable.run(); + return supplier.offer(); } catch (Exception e) { try { conn.rollback(); @@ -147,4 +147,11 @@ public final class DbUtil { } } } + + public static void doTransaction(Connection conn, SQLRunnable runnable) { + doTransaction(conn, () -> { + runnable.run(); + return null; + }); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java new file mode 100644 index 0000000..86b24d4 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java @@ -0,0 +1,25 @@ +package com.andrewlalis.perfin.data; + +import java.util.HashMap; +import java.util.Map; + +public class FileUtil { + public static Map MIMETYPES = new HashMap<>(); + static { + MIMETYPES.put(".pdf", "application/pdf"); + MIMETYPES.put(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + MIMETYPES.put(".odt", "application/vnd.oasis.opendocument.text"); + MIMETYPES.put(".html", "text/html"); + MIMETYPES.put(".txt", "text/plain"); + MIMETYPES.put(".md", "text/markdown"); + MIMETYPES.put(".xml", "application/xml"); + MIMETYPES.put(".json", "application/json"); + MIMETYPES.put(".png", "image/png"); + MIMETYPES.put(".jpg", "image/jpeg"); + MIMETYPES.put(".jpeg", "image/jpeg"); + MIMETYPES.put(".gif", "image/gif"); + MIMETYPES.put(".webp", "image/webp"); + MIMETYPES.put(".bmp", "image/bmp"); + MIMETYPES.put(".tiff", "image/tiff"); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/SQLSupplier.java b/src/main/java/com/andrewlalis/perfin/data/SQLSupplier.java new file mode 100644 index 0000000..45b9e1b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/SQLSupplier.java @@ -0,0 +1,7 @@ +package com.andrewlalis.perfin.data; + +import java.sql.SQLException; + +public interface SQLSupplier { + T offer() throws SQLException; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index efc9fca..11c34f9 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -5,14 +5,18 @@ import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.TransactionAttachment; +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); Page findAll(PageRequest pagination); Page findAllByAccounts(Set accountIds, PageRequest pagination); Map findEntriesWithAccounts(long transactionId); + List findAttachments(long transactionId); void delete(long transactionId); } 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 ab64438..8cb531b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -7,52 +7,81 @@ import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.TransactionAttachment; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.util.*; -import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; public record JdbcTransactionRepository(Connection conn) implements TransactionRepository { @Override public long insert(Transaction transaction, Map accountsMap) { final Timestamp timestamp = DbUtil.timestampFromUtcNow(); - AtomicLong transactionId = new AtomicLong(-1); + return DbUtil.doTransaction(conn, () -> { + long txId = insertTransaction(timestamp, transaction); + insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap); + return txId; + }); + } + + @Override + public void addAttachments(long transactionId, List attachments) { + final Timestamp timestamp = DbUtil.timestampFromUtcNow(); DbUtil.doTransaction(conn, () -> { - // First insert the transaction itself, then add account entries, referencing this transaction. - transactionId.set(DbUtil.insertOne( - conn, - "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", - List.of( - timestamp, - transaction.getAmount(), - transaction.getCurrency().getCurrencyCode(), - transaction.getDescription() - ) - )); - // Now insert an account entry for each affected account. - 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, - transactionId.get(), - transaction.getAmount(), - entryType.name(), - transaction.getCurrency().getCurrencyCode() - )); - stmt.executeUpdate(); - } + 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() + ) + ); } }); - return transactionId.get(); + } + + 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 @@ -98,6 +127,16 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR return map; } + @Override + public List findAttachments(long transactionId) { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_attachment WHERE transaction_id = ? ORDER BY filename ASC", + List.of(transactionId), + JdbcTransactionRepository::parseAttachment + ); + } + @Override public void delete(long transactionId) { DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId)); @@ -117,4 +156,14 @@ 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/TransactionAttachment.java b/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java new file mode 100644 index 0000000..4073698 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionAttachment.java @@ -0,0 +1,61 @@ +package com.andrewlalis.perfin.model; + +import com.andrewlalis.perfin.data.DateUtil; + +import java.nio.file.Path; +import java.time.LocalDateTime; + +/** + * A file that's been attached to a transaction as additional context for it, + * like a receipt or invoice copy. + */ +public class TransactionAttachment { + private long id; + private LocalDateTime uploadedAt; + private long transactionId; + + private String filename; + private String contentType; + + public TransactionAttachment(String filename, String contentType) { + this.filename = filename; + this.contentType = contentType; + } + + public TransactionAttachment(long id, LocalDateTime uploadedAt, long transactionId, String filename, String contentType) { + this.id = id; + this.uploadedAt = uploadedAt; + this.transactionId = transactionId; + this.filename = filename; + this.contentType = contentType; + } + + public long getId() { + return id; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public long getTransactionId() { + return transactionId; + } + + public String getFilename() { + return filename; + } + + public String getContentType() { + return contentType; + } + + public Path getPath() { + String uploadDateStr = uploadedAt.format(DateUtil.DEFAULT_DATE_FORMAT); + return Profile.getContentDir(Profile.getCurrent().getName()) + .resolve("transaction-attachments") + .resolve(uploadDateStr) + .resolve("tx-" + transactionId) + .resolve(filename); + } +} diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index e141449..1742bf0 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -16,7 +16,7 @@ - + + + diff --git a/src/main/resources/create-transaction.fxml b/src/main/resources/create-transaction.fxml index 7d2a9d9..8576e44 100644 --- a/src/main/resources/create-transaction.fxml +++ b/src/main/resources/create-transaction.fxml @@ -10,6 +10,7 @@
+