Added transaction attachments.

This commit is contained in:
Andrew Lalis 2023-12-28 12:57:03 -05:00
parent c648c899cd
commit 616cac6c18
9 changed files with 108 additions and 41 deletions

View File

@ -7,9 +7,12 @@ interface and interoperable file formats for maximum compatibility.
## About Perfin
Perfin is a desktop app built with Java 21 and JavaFX, using the SQLite3
database for most data storage. It's intended to be used by individuals to
track their finances across multiple accounts (savings, checking, credit, etc.).
Perfin is a desktop app built with Java 21 and JavaFX. It's intended to be used
by individuals to track their finances across multiple accounts (savings,
checking, credit, etc.).
Because the app lives and works entirely on your local computer, you can rest
assured that your data remains completely private.
Currently, the application is still a work-in-progress, and is not yet suitable
for actual usage with your real financial data, so stay tuned for updates.

View File

@ -4,7 +4,10 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
@ -34,7 +37,7 @@ public class AccountViewController implements RouteSelectionListener {
accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber());
accountCurrencyField.setText(account.getCurrency().getDisplayName());
accountCreatedAtField.setText(account.getCreatedAt().format(DateUtil.DEFAULT_DATETIME_FORMAT));
accountCreatedAtField.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
Profile.getCurrent().getDataSource().getAccountBalanceText(account, accountBalanceField::setText);
}
@ -43,10 +46,37 @@ public class AccountViewController implements RouteSelectionListener {
router.navigate("edit-account", account);
}
@FXML
public void archiveAccount() {
var confirmResult = new Alert(
Alert.AlertType.CONFIRMATION,
"Are you sure you want to archive this account? It will no " +
"longer show up in the app normally, and you won't be " +
"able to add new transactions to it. You'll still be " +
"able to view the account, and you can un-archive it " +
"later if you need to."
).showAndWait();
if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.archive(account));
router.getHistory().clear();
router.navigate("accounts");
}
}
@FXML
public void deleteAccount() {
var confirmResult = new Alert(
Alert.AlertType.CONFIRMATION,
"Are you sure you want to permanently delete this account and " +
"all data directly associated with it? This cannot be " +
"undone; deleted accounts are not recoverable at all. " +
"Consider archiving this account instead if you just " +
"want to hide it."
).showAndWait();
if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.delete(account));
router.getHistory().clear();
router.navigate("accounts");
}
}
}

View File

@ -19,6 +19,7 @@ public interface AccountRepository extends AutoCloseable {
Optional<Account> findById(long id);
void update(Account account);
void delete(Account account);
void archive(Account account);
BigDecimal deriveBalance(long id, Instant timestamp);
default BigDecimal deriveCurrentBalance(long id) {

View File

@ -69,6 +69,15 @@ public final class DbUtil {
return findOne(conn, query, List.of(id), mapper);
}
public static int update(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
return stmt.executeUpdate();
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static void updateOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);

View File

@ -2,7 +2,6 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.UncheckedSqlException;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
@ -36,14 +35,14 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
@Override
public Page<Account> findAll(PageRequest pagination) {
return DbUtil.findAll(conn, "SELECT * FROM account", pagination, JdbcAccountRepository::parseAccount);
return DbUtil.findAll(conn, "SELECT * FROM account WHERE NOT archived", pagination, JdbcAccountRepository::parseAccount);
}
@Override
public List<Account> findAllByCurrency(Currency currency) {
return DbUtil.findAll(
conn,
"SELECT * FROM account WHERE currency = ? ORDER BY created_at",
"SELECT * FROM account WHERE currency = ? AND NOT archived ORDER BY created_at",
List.of(currency.getCurrencyCode()),
JdbcAccountRepository::parseAccount
);
@ -115,7 +114,7 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
public Set<Currency> findAllUsedCurrencies() {
return new HashSet<>(DbUtil.findAll(
conn,
"SELECT currency FROM account ORDER BY currency ASC",
"SELECT currency FROM account WHERE NOT archived ORDER BY currency ASC",
rs -> Currency.getInstance(rs.getString(1))
));
}
@ -137,23 +136,23 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
@Override
public void delete(Account account) {
try (var stmt = conn.prepareStatement("DELETE FROM account WHERE id = ?")) {
stmt.setLong(1, account.getId());
int rows = stmt.executeUpdate();
if (rows != 1) throw new SQLException("Affected " + rows + " rows instead of expected 1.");
} catch (SQLException e) {
throw new UncheckedSqlException(e);
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.getId()));
}
@Override
public void archive(Account account) {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(account.getId()));
}
public static Account parseAccount(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
boolean archived = rs.getBoolean("archived");
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());
String accountNumber = rs.getString("account_number");
String name = rs.getString("name");
Currency currency = Currency.getInstance(rs.getString("currency"));
return new Account(id, createdAt, type, accountNumber, name, currency);
return new Account(id, createdAt, archived, type, accountNumber, name, currency);
}
@Override

View File

@ -10,15 +10,17 @@ import java.util.Currency;
public class Account {
private long id;
private LocalDateTime createdAt;
private boolean archived;
private AccountType type;
private String accountNumber;
private String name;
private Currency currency;
public Account(long id, LocalDateTime createdAt, AccountType type, String accountNumber, String name, Currency currency) {
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
this.id = id;
this.createdAt = createdAt;
this.archived = archived;
this.type = type;
this.accountNumber = accountNumber;
this.name = name;
@ -26,6 +28,7 @@ public class Account {
}
public Account(AccountType type, String accountNumber, String name, Currency currency) {
this.archived = false;
this.type = type;
this.accountNumber = accountNumber;
this.name = name;
@ -81,4 +84,8 @@ public class Account {
public long getId() {
return id;
}
public boolean isArchived() {
return archived;
}
}

View File

@ -14,6 +14,9 @@
</top>
<center>
<VBox>
<HBox>
<!-- Main account properties. -->
<VBox HBox.hgrow="SOMETIMES">
<VBox styleClass="account-property-box">
<Label text="Name"/>
<TextField fx:id="accountNameField" editable="false"/>
@ -35,11 +38,14 @@
<TextField fx:id="accountBalanceField" editable="false"/>
</VBox>
</VBox>
</HBox>
</VBox>
</center>
<right>
<VBox styleClass="actions-box">
<Label text="Actions" style="-fx-font-weight: bold;"/>
<Button text="Edit" onAction="#goToEditPage"/>
<Button text="Archive" onAction="#archiveAccount"/>
<Button text="Delete" onAction="#deleteAccount"/>
</VBox>
</right>

View File

@ -11,7 +11,7 @@
>
<top>
<VBox>
<HBox>
<HBox style="-fx-spacing: 3px; -fx-padding: 3px;">
<Button text="Back" onAction="#goBack"/>
<Button text="Forward" onAction="#goForward"/>
<Button text="Accounts" onAction="#goToAccounts"/>

View File

@ -1,6 +1,7 @@
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
account_type VARCHAR(31) NOT NULL,
account_number VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(63) NOT NULL,
@ -31,6 +32,17 @@ CREATE TABLE account_entry (
ON UPDATE CASCADE ON DELETE CASCADE
);
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,
content_type VARCHAR(255) NOT NULL,
CONSTRAINT fk_transaction_attachment_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE balance_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL,