Added transaction attachments ability.
This commit is contained in:
parent
616cac6c18
commit
53bfea2bad
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.sql.SQLException;
|
||||
|
||||
public interface SQLSupplier<T> {
|
||||
T offer() throws SQLException;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -7,23 +7,47 @@ 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(
|
||||
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(
|
||||
|
@ -32,8 +56,15 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
transaction.getCurrency().getCurrencyCode(),
|
||||
transaction.getDescription()
|
||||
)
|
||||
));
|
||||
// Now insert an account entry for each affected account.
|
||||
);
|
||||
}
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?)"
|
||||
)) {
|
||||
|
@ -43,7 +74,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
DbUtil.setArgs(stmt, List.of(
|
||||
timestamp,
|
||||
accountId,
|
||||
transactionId.get(),
|
||||
txId,
|
||||
transaction.getAmount(),
|
||||
entryType.name(),
|
||||
transaction.getCurrency().getCurrencyCode()
|
||||
|
@ -51,8 +82,6 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
return transactionId.get();
|
||||
}
|
||||
|
||||
@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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue