Added transaction attachments ability.

This commit is contained in:
Andrew Lalis 2023-12-28 16:19:21 -05:00
parent 616cac6c18
commit 53bfea2bad
11 changed files with 301 additions and 41 deletions

View File

@ -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<Account> linkCreditAccountComboBox;
@FXML public Label linkedAccountsErrorLabel;
private final ObservableList<File> 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<File> 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<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);
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()

View File

@ -125,10 +125,10 @@ public final class DbUtil {
}
}
public static void doTransaction(Connection conn, SQLRunnable runnable) {
public static <T> T doTransaction(Connection conn, SQLSupplier<T> 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;
});
}
}

View File

@ -0,0 +1,25 @@
package com.andrewlalis.perfin.data;
import java.util.HashMap;
import java.util.Map;
public class FileUtil {
public static Map<String, String> 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");
}
}

View File

@ -0,0 +1,7 @@
package com.andrewlalis.perfin.data;
import java.sql.SQLException;
public interface SQLSupplier<T> {
T offer() throws SQLException;
}

View File

@ -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<Long, AccountEntry.Type> accountsMap);
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
Page<Transaction> findAll(PageRequest pagination);
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
List<TransactionAttachment> findAttachments(long transactionId);
void delete(long transactionId);
}

View File

@ -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<Long, AccountEntry.Type> 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<TransactionAttachment> 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<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()
));
stmt.executeUpdate();
}
}
}
@Override
@ -98,6 +127,16 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
return map;
}
@Override
public List<TransactionAttachment> 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")
);
}
}

View File

@ -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);
}
}

View File

@ -16,7 +16,7 @@
<VBox>
<HBox>
<!-- Main account properties. -->
<VBox HBox.hgrow="SOMETIMES">
<VBox HBox.hgrow="SOMETIMES" style="-fx-border-color: blue">
<VBox styleClass="account-property-box">
<Label text="Name"/>
<TextField fx:id="accountNameField" editable="false"/>
@ -38,6 +38,9 @@
<TextField fx:id="accountBalanceField" editable="false"/>
</VBox>
</VBox>
<VBox HBox.hgrow="SOMETIMES" style="-fx-border-color: red">
<Label text="Panel 2"/>
</VBox>
</HBox>
</VBox>
</center>

View File

@ -10,6 +10,7 @@
<center>
<ScrollPane fitToWidth="true" fitToHeight="true">
<VBox styleClass="form-container">
<!-- Basic properties -->
<VBox>
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
<TextField fx:id="timestampField" styleClass="mono-font"/>
@ -30,6 +31,7 @@
<TextArea fx:id="descriptionField" styleClass="mono-font" wrapText="true"/>
<Label fx:id="descriptionErrorLabel" styleClass="error-text" wrapText="true"/>
</VBox>
<!-- Container for linked accounts -->
<VBox>
<HBox spacing="3">
<VBox>
@ -54,6 +56,14 @@
VBox.vgrow="NEVER"
/>
</VBox>
<!-- Container for attachments -->
<VBox>
<Label text="Attachments" styleClass="bold-text"/>
<Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-text" wrapText="true"/>
<VBox fx:id="selectedFilesVBox" style="-fx-spacing: 3px; -fx-padding: 3px;" VBox.vgrow="NEVER"/>
<Label text="No attachments selected." fx:id="noSelectedFilesLabel"/>
<Button text="Select attachments" onAction="#selectAttachmentFile"/>
</VBox>
</VBox>
</ScrollPane>
</center>

View File

@ -36,11 +36,13 @@ CREATE TABLE transaction_attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL,
transaction_id BIGINT NOT NULL,
content_path VARCHAR(1024) NOT NULL,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(255) NOT NULL,
CONSTRAINT fk_transaction_attachment_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT uq_transaction_attachment_filename
UNIQUE(transaction_id, filename)
);
CREATE TABLE balance_record (

View File

@ -5,3 +5,24 @@ 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