Added database utils, cleaned up data logic, added rest of models.

This commit is contained in:
Andrew Lalis 2023-12-16 19:48:04 -05:00
parent 40e5533208
commit 974fbe0b6e
28 changed files with 690 additions and 74 deletions

View File

@ -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 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 database for most data storage. It's intended to be used by individuals to
track their finances across multiple accounts (savings, checking, credit, etc.). 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.

View File

@ -23,12 +23,13 @@ public class PerfinApp extends Application {
initMainScreen(stage); initMainScreen(stage);
splashStage.stateProperty().addListener((v, oldState, state) -> { splashStage.stateProperty().addListener((v, oldState, state) -> {
if (state == SplashScreenStage.State.DONE) stage.show(); if (state == SplashScreenStage.State.DONE) stage.show();
if (state == SplashScreenStage.State.ERROR) System.out.println("ERROR!");
}); });
} }
private void initMainScreen(Stage stage) { private void initMainScreen(Stage stage) {
stage.hide(); stage.hide();
stage.setScene(SceneUtil.load("/main.fxml")); stage.setScene(SceneUtil.load("/accounts-view.fxml"));
stage.setTitle("Perfin"); stage.setTitle("Perfin");
} }
} }

View File

@ -33,7 +33,7 @@ public class AccountTileController {
this.account = account; this.account = account;
Platform.runLater(() -> { Platform.runLater(() -> {
accountNumberLabel.setText(account.getAccountNumber()); accountNumberLabel.setText(account.getAccountNumber());
accountBalanceLabel.setText(account.getCurrency().getSymbol() + " " + account.getCurrentBalance().toPlainString()); accountBalanceLabel.setText(account.getCurrency().getSymbol());
accountNameLabel.setText(account.getName()); accountNameLabel.setText(account.getName());
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
System.out.println("Clicked on " + account.getAccountNumber()); System.out.println("Clicked on " + account.getAccountNumber());

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.SplashScreenStage; import com.andrewlalis.perfin.view.SplashScreenStage;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.fxml.FXML; import javafx.fxml.FXML;
@ -29,10 +30,22 @@ public class StartupSplashScreenController {
try { try {
printlnLater("Initializing application files..."); printlnLater("Initializing application files...");
if (!initAppDir()) { if (!initAppDir()) {
Thread.sleep(3000);
Platform.runLater(() -> getSplashStage().setError()); Platform.runLater(() -> getSplashStage().setError());
return; 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."); printlnLater("Perfin initialized. Starting the app now.");
Thread.sleep(500); Thread.sleep(500);

View File

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

View File

@ -0,0 +1,5 @@
package com.andrewlalis.perfin.data;
public interface DataSource {
AccountRepository getAccountRepository();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
package com.andrewlalis.perfin.model; package com.andrewlalis.perfin.model;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Currency; import java.util.Currency;
@ -14,14 +13,21 @@ public class Account {
private AccountType type; private AccountType type;
private String accountNumber; private String accountNumber;
private BigDecimal currentBalance;
private String name; private String name;
private Currency currency; 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.type = type;
this.accountNumber = accountNumber; this.accountNumber = accountNumber;
this.currentBalance = currentBalance;
this.name = name; this.name = name;
this.currency = currency; this.currency = currency;
} }
@ -34,10 +40,6 @@ public class Account {
return accountNumber; return accountNumber;
} }
public BigDecimal getCurrentBalance() {
return currentBalance;
}
public String getName() { public String getName() {
return name; return name;
} }

View File

@ -5,7 +5,8 @@ import java.time.LocalDateTime;
import java.util.Currency; 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> * <p>
* The following rules apply in determining the type of an entry: * The following rules apply in determining the type of an entry:
* </p> * </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>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> * <li>A <em>credit</em> indicates a decrease in assets or increase in liability.</li>
* </ul> * </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 class AccountEntry {
public enum Type { public enum Type {
@ -23,21 +39,16 @@ public class AccountEntry {
private long id; private long id;
private LocalDateTime timestamp; private LocalDateTime timestamp;
private long accountId; private long accountId;
private long transactionId;
private BigDecimal amount; private BigDecimal amount;
private Type type; private Type type;
private Currency currency; 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.id = id;
this.timestamp = timestamp; this.timestamp = timestamp;
this.accountId = accountId; this.accountId = accountId;
this.amount = amount; this.transactionId = transactionId;
this.type = type;
this.currency = currency;
}
public AccountEntry(long accountId, BigDecimal amount, Type type, Currency currency) {
this.accountId = accountId;
this.amount = amount; this.amount = amount;
this.type = type; this.type = type;
this.currency = currency; this.currency = currency;

View File

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

View File

@ -1,5 +1,20 @@
package com.andrewlalis.perfin.model; 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 * 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 * 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 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 * and can be encrypted for additional security. Each profile also has its own
* settings. * settings.
* Practically, each profile is stored as its own isolated database file, with * <p>
* a name corresponding to the profile's name. * 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 { 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+");
}
} }

View File

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

View File

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

View File

@ -6,6 +6,8 @@ module com.andrewlalis.perfin {
requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.databind;
requires java.sql;
exports com.andrewlalis.perfin to javafx.graphics; exports com.andrewlalis.perfin to javafx.graphics;
opens com.andrewlalis.perfin.control to javafx.fxml; opens com.andrewlalis.perfin.control to javafx.fxml;
} }

View File

@ -6,15 +6,13 @@
<VBox <VBox
styleClass="account-tile-container" styleClass="account-tile-container"
prefHeight="100.0" prefHeight="100.0"
prefWidth="200.0" prefWidth="300.0"
stylesheets="@style/account-tile.css" stylesheets="@style/account-tile.css"
xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns="http://javafx.com/javafx/17.0.2-ea"
xmlns:fx="http://javafx.com/fxml/1" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.andrewlalis.perfin.control.AccountTileController" fx:controller="com.andrewlalis.perfin.control.AccountTileController"
fx:id="container" fx:id="container"
> >
<Label styleClass="main-label" text="Account Info" />
<Separator prefWidth="200.0" />
<Label text="Account Number" styleClass="property-label"/> <Label text="Account Number" styleClass="property-label"/>
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" /> <Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
<Label text="Account Balance" styleClass="property-label"/> <Label text="Account Balance" styleClass="property-label"/>

View File

@ -8,9 +8,9 @@
minWidth="600.0" minWidth="600.0"
xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns="http://javafx.com/javafx/17.0.2-ea"
xmlns:fx="http://javafx.com/fxml/1" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.andrewlalis.perfin.control.MainController" fx:controller="com.andrewlalis.perfin.control.AccountsViewController"
fx:id="mainContainer" fx:id="mainContainer"
stylesheets="@style/main.css" stylesheets="@style/accounts-view.css"
> >
<top> <top>
<MenuBar BorderPane.alignment="CENTER"> <MenuBar BorderPane.alignment="CENTER">

View File

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

View File

@ -2,6 +2,7 @@
-fx-border-color: lightgray; -fx-border-color: lightgray;
-fx-border-width: 1px; -fx-border-width: 1px;
-fx-border-style: solid; -fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px; -fx-padding: 5px;
} }

View File

@ -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.

View File

@ -0,0 +1 @@
my-setting=true

View File

@ -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.