Added transaction attachments.
This commit is contained in:
parent
c648c899cd
commit
616cac6c18
|
@ -7,9 +7,12 @@ interface and interoperable file formats for maximum compatibility.
|
||||||
|
|
||||||
## About Perfin
|
## About Perfin
|
||||||
|
|
||||||
Perfin is a desktop app built with Java 21 and JavaFX, using the SQLite3
|
Perfin is a desktop app built with Java 21 and JavaFX. It's intended to be used
|
||||||
database for most data storage. It's intended to be used by individuals to
|
by individuals to track their finances across multiple accounts (savings,
|
||||||
track their finances across multiple accounts (savings, checking, credit, etc.).
|
checking, credit, etc.).
|
||||||
|
|
||||||
Because the app lives and works entirely on your local computer, you can rest
|
Because the app lives and works entirely on your local computer, you can rest
|
||||||
assured that your data remains completely private.
|
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.
|
||||||
|
|
|
@ -4,7 +4,10 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.DateUtil;
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import javafx.event.ActionEvent;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
|
||||||
|
@ -34,7 +37,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
accountNameField.setText(account.getName());
|
accountNameField.setText(account.getName());
|
||||||
accountNumberField.setText(account.getAccountNumber());
|
accountNumberField.setText(account.getAccountNumber());
|
||||||
accountCurrencyField.setText(account.getCurrency().getDisplayName());
|
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);
|
Profile.getCurrent().getDataSource().getAccountBalanceText(account, accountBalanceField::setText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,10 +46,37 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
router.navigate("edit-account", account);
|
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
|
@FXML
|
||||||
public void deleteAccount() {
|
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));
|
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.delete(account));
|
||||||
router.getHistory().clear();
|
router.getHistory().clear();
|
||||||
router.navigate("accounts");
|
router.navigate("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ public interface AccountRepository extends AutoCloseable {
|
||||||
Optional<Account> findById(long id);
|
Optional<Account> findById(long id);
|
||||||
void update(Account account);
|
void update(Account account);
|
||||||
void delete(Account account);
|
void delete(Account account);
|
||||||
|
void archive(Account account);
|
||||||
|
|
||||||
BigDecimal deriveBalance(long id, Instant timestamp);
|
BigDecimal deriveBalance(long id, Instant timestamp);
|
||||||
default BigDecimal deriveCurrentBalance(long id) {
|
default BigDecimal deriveCurrentBalance(long id) {
|
||||||
|
|
|
@ -69,6 +69,15 @@ public final class DbUtil {
|
||||||
return findOne(conn, query, List.of(id), mapper);
|
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) {
|
public static void updateOne(Connection conn, String query, List<Object> args) {
|
||||||
try (var stmt = conn.prepareStatement(query)) {
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
|
|
|
@ -2,7 +2,6 @@ package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.DbUtil;
|
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.Page;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
@ -36,14 +35,14 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<Account> findAll(PageRequest pagination) {
|
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
|
@Override
|
||||||
public List<Account> findAllByCurrency(Currency currency) {
|
public List<Account> findAllByCurrency(Currency currency) {
|
||||||
return DbUtil.findAll(
|
return DbUtil.findAll(
|
||||||
conn,
|
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()),
|
List.of(currency.getCurrencyCode()),
|
||||||
JdbcAccountRepository::parseAccount
|
JdbcAccountRepository::parseAccount
|
||||||
);
|
);
|
||||||
|
@ -115,7 +114,7 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
||||||
public Set<Currency> findAllUsedCurrencies() {
|
public Set<Currency> findAllUsedCurrencies() {
|
||||||
return new HashSet<>(DbUtil.findAll(
|
return new HashSet<>(DbUtil.findAll(
|
||||||
conn,
|
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))
|
rs -> Currency.getInstance(rs.getString(1))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -137,23 +136,23 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(Account account) {
|
public void delete(Account account) {
|
||||||
try (var stmt = conn.prepareStatement("DELETE FROM account WHERE id = ?")) {
|
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.getId()));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
public static Account parseAccount(ResultSet rs) throws SQLException {
|
||||||
long id = rs.getLong("id");
|
long id = rs.getLong("id");
|
||||||
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
|
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
|
||||||
|
boolean archived = rs.getBoolean("archived");
|
||||||
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());
|
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());
|
||||||
String accountNumber = rs.getString("account_number");
|
String accountNumber = rs.getString("account_number");
|
||||||
String name = rs.getString("name");
|
String name = rs.getString("name");
|
||||||
Currency currency = Currency.getInstance(rs.getString("currency"));
|
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
|
@Override
|
||||||
|
|
|
@ -10,15 +10,17 @@ import java.util.Currency;
|
||||||
public class Account {
|
public class Account {
|
||||||
private long id;
|
private long id;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private boolean archived;
|
||||||
|
|
||||||
private AccountType type;
|
private AccountType type;
|
||||||
private String accountNumber;
|
private String accountNumber;
|
||||||
private String name;
|
private String name;
|
||||||
private Currency currency;
|
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.id = id;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
|
this.archived = archived;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
@ -26,6 +28,7 @@ public class Account {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Account(AccountType type, String accountNumber, String name, Currency currency) {
|
public Account(AccountType type, String accountNumber, String name, Currency currency) {
|
||||||
|
this.archived = false;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
@ -81,4 +84,8 @@ public class Account {
|
||||||
public long getId() {
|
public long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isArchived() {
|
||||||
|
return archived;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,9 @@
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<center>
|
||||||
<VBox>
|
<VBox>
|
||||||
|
<HBox>
|
||||||
|
<!-- Main account properties. -->
|
||||||
|
<VBox HBox.hgrow="SOMETIMES">
|
||||||
<VBox styleClass="account-property-box">
|
<VBox styleClass="account-property-box">
|
||||||
<Label text="Name"/>
|
<Label text="Name"/>
|
||||||
<TextField fx:id="accountNameField" editable="false"/>
|
<TextField fx:id="accountNameField" editable="false"/>
|
||||||
|
@ -35,11 +38,14 @@
|
||||||
<TextField fx:id="accountBalanceField" editable="false"/>
|
<TextField fx:id="accountBalanceField" editable="false"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
</HBox>
|
||||||
|
</VBox>
|
||||||
</center>
|
</center>
|
||||||
<right>
|
<right>
|
||||||
<VBox styleClass="actions-box">
|
<VBox styleClass="actions-box">
|
||||||
<Label text="Actions" style="-fx-font-weight: bold;"/>
|
<Label text="Actions" style="-fx-font-weight: bold;"/>
|
||||||
<Button text="Edit" onAction="#goToEditPage"/>
|
<Button text="Edit" onAction="#goToEditPage"/>
|
||||||
|
<Button text="Archive" onAction="#archiveAccount"/>
|
||||||
<Button text="Delete" onAction="#deleteAccount"/>
|
<Button text="Delete" onAction="#deleteAccount"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
</right>
|
</right>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
>
|
>
|
||||||
<top>
|
<top>
|
||||||
<VBox>
|
<VBox>
|
||||||
<HBox>
|
<HBox style="-fx-spacing: 3px; -fx-padding: 3px;">
|
||||||
<Button text="Back" onAction="#goBack"/>
|
<Button text="Back" onAction="#goBack"/>
|
||||||
<Button text="Forward" onAction="#goForward"/>
|
<Button text="Forward" onAction="#goForward"/>
|
||||||
<Button text="Accounts" onAction="#goToAccounts"/>
|
<Button text="Accounts" onAction="#goToAccounts"/>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
CREATE TABLE account (
|
CREATE TABLE account (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
created_at TIMESTAMP NOT NULL,
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
account_type VARCHAR(31) NOT NULL,
|
account_type VARCHAR(31) NOT NULL,
|
||||||
account_number VARCHAR(255) NOT NULL UNIQUE,
|
account_number VARCHAR(255) NOT NULL UNIQUE,
|
||||||
name VARCHAR(63) NOT NULL,
|
name VARCHAR(63) NOT NULL,
|
||||||
|
@ -31,6 +32,17 @@ CREATE TABLE account_entry (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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 (
|
CREATE TABLE balance_record (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
timestamp TIMESTAMP NOT NULL,
|
timestamp TIMESTAMP NOT NULL,
|
||||||
|
|
Loading…
Reference in New Issue