Added database utils, cleaned up data logic, added rest of models.
This commit is contained in:
parent
40e5533208
commit
974fbe0b6e
|
@ -10,3 +10,6 @@ interface and interoperable file formats for maximum compatibility.
|
|||
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.).
|
||||
|
||||
Because the app lives and works entirely on your local computer, you can rest
|
||||
assured that your data remains completely private.
|
||||
|
|
|
@ -23,12 +23,13 @@ public class PerfinApp extends Application {
|
|||
initMainScreen(stage);
|
||||
splashStage.stateProperty().addListener((v, oldState, state) -> {
|
||||
if (state == SplashScreenStage.State.DONE) stage.show();
|
||||
if (state == SplashScreenStage.State.ERROR) System.out.println("ERROR!");
|
||||
});
|
||||
}
|
||||
|
||||
private void initMainScreen(Stage stage) {
|
||||
stage.hide();
|
||||
stage.setScene(SceneUtil.load("/main.fxml"));
|
||||
stage.setScene(SceneUtil.load("/accounts-view.fxml"));
|
||||
stage.setTitle("Perfin");
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ public class AccountTileController {
|
|||
this.account = account;
|
||||
Platform.runLater(() -> {
|
||||
accountNumberLabel.setText(account.getAccountNumber());
|
||||
accountBalanceLabel.setText(account.getCurrency().getSymbol() + " " + account.getCurrentBalance().toPlainString());
|
||||
accountBalanceLabel.setText(account.getCurrency().getSymbol());
|
||||
accountNameLabel.setText(account.getName());
|
||||
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
||||
System.out.println("Clicked on " + account.getAccountNumber());
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.perfin.SceneUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class AccountsViewController {
|
||||
@FXML
|
||||
public BorderPane mainContainer;
|
||||
@FXML
|
||||
public FlowPane accountsPane;
|
||||
|
||||
private final ObservableList<Account> accountsList = FXCollections.observableArrayList();
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
|
||||
|
||||
BindingUtil.mapContent(accountsPane.getChildren(), accountsList, account -> SceneUtil.loadNode(
|
||||
"/account-tile.fxml",
|
||||
(Consumer<AccountTileController>) c -> c.setAccount(account)
|
||||
));
|
||||
Profile.whenLoaded(profile -> {
|
||||
populateAccounts(profile.getDataSource().getAccountRepository().findAll());
|
||||
});
|
||||
}
|
||||
|
||||
private void populateAccounts(List<Account> accounts) {
|
||||
this.accountsList.clear();
|
||||
this.accountsList.addAll(accounts);
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.perfin.SceneUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class MainController {
|
||||
@FXML
|
||||
public BorderPane mainContainer;
|
||||
@FXML
|
||||
public FlowPane accountsPane;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
|
||||
List<Account> tempAccounts = List.of(
|
||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD"))
|
||||
);
|
||||
populateAccounts(tempAccounts);
|
||||
}
|
||||
|
||||
private void populateAccounts(List<Account> accounts) {
|
||||
accountsPane.getChildren().clear();
|
||||
for (var account : accounts) {
|
||||
Parent node = SceneUtil.loadNode(
|
||||
"/account-tile.fxml",
|
||||
(Consumer<AccountTileController>) c -> c.setAccount(account)
|
||||
);
|
||||
accountsPane.getChildren().add(node);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.SplashScreenStage;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
|
@ -29,10 +30,22 @@ public class StartupSplashScreenController {
|
|||
try {
|
||||
printlnLater("Initializing application files...");
|
||||
if (!initAppDir()) {
|
||||
Thread.sleep(3000);
|
||||
Platform.runLater(() -> getSplashStage().setError());
|
||||
return;
|
||||
}
|
||||
|
||||
printlnLater("Loading the last profile...");
|
||||
try {
|
||||
Profile.loadLast();
|
||||
} catch (Exception e) {
|
||||
printlnLater("Failed to load profile: " + e.getMessage());
|
||||
Thread.sleep(3000);
|
||||
Platform.runLater(() -> getSplashStage().setError());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
printlnLater("Perfin initialized. Starting the app now.");
|
||||
Thread.sleep(500);
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AccountRepository {
|
||||
long insert(Account account);
|
||||
List<Account> findAll();
|
||||
Optional<Account> findById(long id);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
public interface DataSource {
|
||||
AccountRepository getAccountRepository();
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class DbUtil {
|
||||
private DbUtil() {}
|
||||
|
||||
public static void setArgs(PreparedStatement stmt, List<Object> args) {
|
||||
for (int i = 0; i < args.size(); i++) {
|
||||
try {
|
||||
stmt.setObject(i + 1, args.get(i));
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args.get(i), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
var rs = stmt.executeQuery();
|
||||
List<T> results = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
results.add(mapper.map(rs));
|
||||
}
|
||||
return results;
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> List<T> findAll(Connection conn, String query, ResultSetMapper<T> mapper) {
|
||||
return findAll(conn, query, Collections.emptyList(), mapper);
|
||||
}
|
||||
|
||||
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
var rs = stmt.executeQuery();
|
||||
if (!rs.next()) return Optional.empty();
|
||||
return Optional.of(mapper.map(rs));
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> Optional<T> findById(Connection conn, String query, long id, ResultSetMapper<T> mapper) {
|
||||
return findOne(conn, query, List.of(id), mapper);
|
||||
}
|
||||
|
||||
public static void updateOne(Connection conn, String query, List<Object> args) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
int updateCount = stmt.executeUpdate();
|
||||
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static long insertOne(Connection conn, String query, List<Object> args) {
|
||||
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
||||
setArgs(stmt, args);
|
||||
int result = stmt.executeUpdate();
|
||||
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
||||
var rs = stmt.getGeneratedKeys();
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
||||
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
public static Timestamp timestampFromUtcNow() {
|
||||
return Timestamp.from(Instant.now(Clock.systemUTC()));
|
||||
}
|
||||
|
||||
public static LocalDateTime utcLDTFromTimestamp(Timestamp ts) {
|
||||
return ts.toInstant().atOffset(ZoneOffset.UTC).toLocalDateTime();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ResultSetMapper<T> {
|
||||
T map(ResultSet rs) throws SQLException;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class UncheckedSqlException extends RuntimeException {
|
||||
public UncheckedSqlException(SQLException cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public UncheckedSqlException(String message, SQLException cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public UncheckedSqlException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||
@Override
|
||||
public long insert(Account account) {
|
||||
return DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcNow(),
|
||||
account.getType().name(),
|
||||
account.getAccountNumber(),
|
||||
account.getName(),
|
||||
account.getCurrency().getCurrencyCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Account> findAll() {
|
||||
return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Account> findById(long id) {
|
||||
return DbUtil.findById(conn, "SELECT * FROM account WHERE id = ?", id, JdbcAccountRepository::parseAccount);
|
||||
}
|
||||
|
||||
private static Account parseAccount(ResultSet rs) throws SQLException {
|
||||
long id = rs.getLong("id");
|
||||
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.UncheckedSqlException;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* A basic data source implementation that gets a new SQL connection using a
|
||||
* pre-defined JDBC connection URL.
|
||||
*/
|
||||
public class JdbcDataSource implements DataSource {
|
||||
private final String jdbcUrl;
|
||||
|
||||
public JdbcDataSource(String jdbcUrl) {
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
public Connection getConnection() {
|
||||
try {
|
||||
return DriverManager.getConnection(jdbcUrl);
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountRepository getAccountRepository() {
|
||||
return new JdbcAccountRepository(getConnection());
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
|
||||
|
@ -14,14 +13,21 @@ public class Account {
|
|||
|
||||
private AccountType type;
|
||||
private String accountNumber;
|
||||
private BigDecimal currentBalance;
|
||||
private String name;
|
||||
private Currency currency;
|
||||
|
||||
public Account(AccountType type, String accountNumber, BigDecimal currentBalance, String name, Currency currency) {
|
||||
public Account(long id, LocalDateTime createdAt, AccountType type, String accountNumber, String name, Currency currency) {
|
||||
this.id = id;
|
||||
this.createdAt = createdAt;
|
||||
this.type = type;
|
||||
this.accountNumber = accountNumber;
|
||||
this.name = name;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public Account(AccountType type, String accountNumber, String name, Currency currency) {
|
||||
this.type = type;
|
||||
this.accountNumber = accountNumber;
|
||||
this.currentBalance = currentBalance;
|
||||
this.name = name;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
@ -34,10 +40,6 @@ public class Account {
|
|||
return accountNumber;
|
||||
}
|
||||
|
||||
public BigDecimal getCurrentBalance() {
|
||||
return currentBalance;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import java.time.LocalDateTime;
|
|||
import java.util.Currency;
|
||||
|
||||
/**
|
||||
* A single entry depicting a credit or debit to an account.
|
||||
* A single entry depicting a credit or debit to an account, according to the
|
||||
* rules of single-entry accounting.
|
||||
* <p>
|
||||
* The following rules apply in determining the type of an entry:
|
||||
* </p>
|
||||
|
@ -13,6 +14,21 @@ import java.util.Currency;
|
|||
* <li>A <em>debit</em> indicates an increase in assets or decrease in liability.</li>
|
||||
* <li>A <em>credit</em> indicates a decrease in assets or increase in liability.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* For example, for a checking account, a debit entry is added when money
|
||||
* is transferred to the account, and credit when money is taken away.
|
||||
* </p>
|
||||
* <p>
|
||||
* Each entry corresponds to exactly one transaction. For pretty much
|
||||
* everything but personal transfers, one transaction maps to one entry,
|
||||
* but for transferring from one account to another, you'll have one
|
||||
* transaction and an entry for each account.
|
||||
* </p>
|
||||
* <p>
|
||||
* We don't use double-entry accounting since we're just tracking personal
|
||||
* accounts, so we don't need the granularity of business accounting, and
|
||||
* all those extra accounts would be a burden to casual users.
|
||||
* </p>
|
||||
*/
|
||||
public class AccountEntry {
|
||||
public enum Type {
|
||||
|
@ -23,21 +39,16 @@ public class AccountEntry {
|
|||
private long id;
|
||||
private LocalDateTime timestamp;
|
||||
private long accountId;
|
||||
private long transactionId;
|
||||
private BigDecimal amount;
|
||||
private Type type;
|
||||
private Currency currency;
|
||||
|
||||
public AccountEntry(long id, LocalDateTime timestamp, long accountId, BigDecimal amount, Type type, Currency currency) {
|
||||
public AccountEntry(long id, LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) {
|
||||
this.id = id;
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.amount = amount;
|
||||
this.type = type;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public AccountEntry(long accountId, BigDecimal amount, Type type, Currency currency) {
|
||||
this.accountId = accountId;
|
||||
this.transactionId = transactionId;
|
||||
this.amount = amount;
|
||||
this.type = type;
|
||||
this.currency = currency;
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
|
||||
/**
|
||||
* A recording of an account's real reported balance at a given point in time,
|
||||
* used as a sanity check for ensuring that an account's entries add up to the
|
||||
* correct balance.
|
||||
*/
|
||||
public class BalanceRecord {
|
||||
private long id;
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
private long accountId;
|
||||
private BigDecimal balance;
|
||||
private Currency currency;
|
||||
|
||||
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) {
|
||||
this.id = id;
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.balance = balance;
|
||||
this.currency = currency;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,20 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import com.andrewlalis.perfin.PerfinApp;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A profile is essentially a complete set of data that the application can
|
||||
* operate on, sort of like a save file or user account. The profile contains
|
||||
|
@ -7,10 +22,165 @@ package com.andrewlalis.perfin.model;
|
|||
* and more. A profile can be imported or exported easily from the application,
|
||||
* and can be encrypted for additional security. Each profile also has its own
|
||||
* settings.
|
||||
* Practically, each profile is stored as its own isolated database file, with
|
||||
* a name corresponding to the profile's name.
|
||||
* <p>
|
||||
* Practically, each profile is a directory containing a database file,
|
||||
* settings, files, and other information.
|
||||
* </p>
|
||||
* <p>
|
||||
* Because only one profile may be loaded in the app at once, the Profile
|
||||
* class maintains a static <em>current</em> profile that can be loaded and
|
||||
* unloaded.
|
||||
* </p>
|
||||
*/
|
||||
public class Profile {
|
||||
private String name;
|
||||
private static Profile current;
|
||||
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
|
||||
|
||||
private final String name;
|
||||
private final Properties settings;
|
||||
private final DataSource dataSource;
|
||||
|
||||
private Profile(String name, Properties settings, DataSource dataSource) {
|
||||
this.name = name;
|
||||
this.settings = settings;
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Properties getSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public DataSource getDataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
public static Path getDir(String name) {
|
||||
return PerfinApp.APP_DIR.resolve(name);
|
||||
}
|
||||
|
||||
public static Path getContentDir(String name) {
|
||||
return getDir(name).resolve("content");
|
||||
}
|
||||
|
||||
public static Path getSettingsFile(String name) {
|
||||
return getDir(name).resolve("settings.properties");
|
||||
}
|
||||
|
||||
public static Path getDatabaseFile(String name) {
|
||||
return getDir(name).resolve("database.mv.db");
|
||||
}
|
||||
|
||||
public static Profile getCurrent() {
|
||||
return current;
|
||||
}
|
||||
|
||||
public static void whenLoaded(Consumer<Profile> consumer) {
|
||||
if (current != null) {
|
||||
consumer.accept(current);
|
||||
} else {
|
||||
profileLoadListeners.add(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getLastProfile() {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
if (Files.exists(lastProfileFile)) {
|
||||
try {
|
||||
String s = Files.readString(lastProfileFile).strip().toLowerCase();
|
||||
if (!s.isBlank()) return s;
|
||||
} catch (IOException e) {
|
||||
System.err.println("Failed to read " + lastProfileFile);
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
public static void saveLastProfile(String name) {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
try {
|
||||
Files.writeString(lastProfileFile, name);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Failed to write " + lastProfileFile);
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
public static void loadLast() throws Exception {
|
||||
load(getLastProfile());
|
||||
}
|
||||
|
||||
public static void load(String name) throws IOException {
|
||||
if (Files.notExists(getDir(name))) {
|
||||
initProfileDir(name);
|
||||
}
|
||||
Properties settings = new Properties();
|
||||
try (var in = Files.newInputStream(getSettingsFile(name))) {
|
||||
settings.load(in);
|
||||
}
|
||||
current = new Profile(name, settings, initJdbcDataSource(name));
|
||||
saveLastProfile(current.getName());
|
||||
for (var c : profileLoadListeners) {
|
||||
c.accept(current);
|
||||
}
|
||||
profileLoadListeners.clear();
|
||||
}
|
||||
|
||||
private static void initProfileDir(String name) throws IOException {
|
||||
Files.createDirectory(getDir(name));
|
||||
copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
|
||||
copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
|
||||
Files.createDirectory(getContentDir(name));
|
||||
copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
|
||||
}
|
||||
|
||||
private static DataSource initJdbcDataSource(String name) throws IOException {
|
||||
String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
|
||||
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
|
||||
boolean exists = Files.exists(getDatabaseFile(name));
|
||||
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl);
|
||||
if (!exists) {// Initialize the datasource using schema.sql.
|
||||
try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
|
||||
if (in == null) throw new IOException("Could not load /sql/schema.sql");
|
||||
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
List<String> statements = Arrays.stream(schemaStr.split(";"))
|
||||
.map(String::strip).filter(s -> !s.isBlank()).toList();
|
||||
for (var statementStr : statements) {
|
||||
try (var stmt = conn.createStatement()) {
|
||||
stmt.executeUpdate(statementStr);
|
||||
System.out.println("Executed update:\n" + statementStr + "\n-----");
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
Files.deleteIfExists(getDatabaseFile(name));
|
||||
throw new IOException("Failed to initialize database.", e);
|
||||
}
|
||||
}
|
||||
// Test the datasource before returning it.
|
||||
try (var conn = dataSource.getConnection(); var s = conn.createStatement()) {
|
||||
boolean success = s.execute("SELECT 1;");
|
||||
if (!success) throw new IOException("Failed to execute DB test statement.");
|
||||
} catch (SQLException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
private static void copyResourceFile(String resource, Path dest) throws IOException {
|
||||
try (
|
||||
var in = Profile.class.getResourceAsStream(resource);
|
||||
var out = Files.newOutputStream(dest)
|
||||
) {
|
||||
if (in == null) throw new IOException("Could not load resource " + resource);
|
||||
in.transferTo(out);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validateName(String name) {
|
||||
return name.matches("\\w+");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
|
||||
/**
|
||||
* A transaction is a permanent record of a transfer of funds between two
|
||||
* accounts.
|
||||
*/
|
||||
public class Transaction {
|
||||
private long id;
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
private BigDecimal amount;
|
||||
private Currency currency;
|
||||
private String description;
|
||||
|
||||
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
|
||||
this.id = id;
|
||||
this.timestamp = timestamp;
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.description = description;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package com.andrewlalis.perfin.view;
|
||||
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class BindingUtil {
|
||||
|
||||
public static <E, F> void mapContent(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||
Function<? super E, ? extends F> mapper) {
|
||||
map(mapped, source, mapper);
|
||||
}
|
||||
|
||||
private static <E, F> Object map(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||
Function<? super E, ? extends F> mapper) {
|
||||
final ListContentMapping<E, F> contentMapping = new ListContentMapping<>(mapped, mapper);
|
||||
mapped.setAll(source.stream().map(mapper).toList());
|
||||
source.removeListener(contentMapping);
|
||||
source.addListener(contentMapping);
|
||||
return contentMapping;
|
||||
}
|
||||
|
||||
private static class ListContentMapping<E, F> implements ListChangeListener<E>, WeakListener {
|
||||
private final WeakReference<List<F>> mappedRef;
|
||||
private final Function<? super E, ? extends F> mapper;
|
||||
|
||||
public ListContentMapping(List<F> mapped, Function<? super E, ? extends F> mapper) {
|
||||
this.mappedRef = new WeakReference<>(mapped);
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged(Change<? extends E> change) {
|
||||
final List<F> mapped = mappedRef.get();
|
||||
if (mapped == null) {
|
||||
change.getList().removeListener(this);
|
||||
} else {
|
||||
while (change.next()) {
|
||||
if (change.wasPermutated()) {
|
||||
mapped.subList(change.getFrom(), change.getTo()).clear();
|
||||
mapped.addAll(change.getFrom(), change.getList().subList(change.getFrom(), change.getTo())
|
||||
.stream().map(mapper).toList());
|
||||
} else {
|
||||
if (change.wasRemoved()) {
|
||||
mapped.subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear();
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
mapped.addAll(change.getFrom(), change.getAddedSubList()
|
||||
.stream().map(mapper).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return mappedRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
final List<F> list = mappedRef.get();
|
||||
return (list == null) ? 0 : list.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final List<F> mapped1 = mappedRef.get();
|
||||
if (mapped1 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (obj instanceof ListContentMapping<?, ?> other) {
|
||||
final List<?> mapped2 = other.mappedRef.get();
|
||||
return mapped1 == mapped2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,8 @@ module com.andrewlalis.perfin {
|
|||
|
||||
requires com.fasterxml.jackson.databind;
|
||||
|
||||
requires java.sql;
|
||||
|
||||
exports com.andrewlalis.perfin to javafx.graphics;
|
||||
opens com.andrewlalis.perfin.control to javafx.fxml;
|
||||
}
|
|
@ -6,15 +6,13 @@
|
|||
<VBox
|
||||
styleClass="account-tile-container"
|
||||
prefHeight="100.0"
|
||||
prefWidth="200.0"
|
||||
prefWidth="300.0"
|
||||
stylesheets="@style/account-tile.css"
|
||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="com.andrewlalis.perfin.control.AccountTileController"
|
||||
fx:id="container"
|
||||
>
|
||||
<Label styleClass="main-label" text="Account Info" />
|
||||
<Separator prefWidth="200.0" />
|
||||
<Label text="Account Number" styleClass="property-label"/>
|
||||
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
|
||||
<Label text="Account Balance" styleClass="property-label"/>
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
minWidth="600.0"
|
||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="com.andrewlalis.perfin.control.MainController"
|
||||
fx:controller="com.andrewlalis.perfin.control.AccountsViewController"
|
||||
fx:id="mainContainer"
|
||||
stylesheets="@style/main.css"
|
||||
stylesheets="@style/accounts-view.css"
|
||||
>
|
||||
<top>
|
||||
<MenuBar BorderPane.alignment="CENTER">
|
|
@ -0,0 +1,43 @@
|
|||
CREATE TABLE account (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
account_type VARCHAR(31) NOT NULL,
|
||||
account_number VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(63) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE transaction (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
amount NUMERIC(12, 4) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
description VARCHAR(255) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE account_entry (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
account_id BIGINT NOT NULL,
|
||||
transaction_id BIGINT NOT NULL,
|
||||
amount NUMERIC(12, 4) NOT NULL,
|
||||
type ENUM('CREDIT', 'DEBIT') NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
CONSTRAINT fk_account_entry_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_entry_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,
|
||||
account_id BIGINT NOT NULL,
|
||||
balance NUMERIC(12, 4) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
CONSTRAINT fk_balance_record_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
|
@ -2,6 +2,7 @@
|
|||
-fx-border-color: lightgray;
|
||||
-fx-border-width: 1px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 5px;
|
||||
-fx-padding: 5px;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
This is your Perfin profile's content directory.
|
||||
|
||||
It contains all the files and other large content that you've added to your
|
||||
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.
|
|
@ -0,0 +1 @@
|
|||
my-setting=true
|
|
@ -0,0 +1,4 @@
|
|||
This is your Perfin profile's main directory.
|
||||
|
||||
All the data contained in your Perfin profile is stored in this directory,
|
||||
organized into a few separate locations based on the data's usage and purpose.
|
Loading…
Reference in New Issue