Compare commits
84 Commits
Author | SHA1 | Date |
---|---|---|
|
86ee9f8187 | |
|
36c29e0d06 | |
|
b91b0a8263 | |
|
6b63b777cf | |
|
71cc5b1612 | |
|
6d720b9645 | |
|
408d5e415d | |
|
3908515ca4 | |
|
b74119a233 | |
|
2abbd6ca43 | |
|
f23d2c85a9 | |
|
ec6bc83353 | |
|
feda2e1897 | |
|
d4bd5cc6ec | |
|
83e9043057 | |
|
ea94f09702 | |
|
411f384775 | |
|
72d624afdc | |
|
2dbb3d944d | |
|
a88ebc8e13 | |
|
d360de5d6f | |
|
6e862a2709 | |
|
b6fef8d42f | |
|
e08c528b71 | |
|
28002fd32d | |
|
a3558b33e6 | |
|
5ce2360f05 | |
|
4cf95dba85 | |
|
e6d5b280aa | |
|
1898783c56 | |
|
77f2966291 | |
|
20eed2108f | |
|
e4783e5a47 | |
|
a13c9c22df | |
|
8f5ff09891 | |
|
06d9aa016d | |
|
a9cdc6c41e | |
|
9222b8f990 | |
|
d85ff6676e | |
|
7f65466d6d | |
|
b52148fd3b | |
|
9bc4d1e494 | |
|
012b60d1f8 | |
|
41530d5276 | |
|
5cc789419c | |
|
cc83e3eb0f | |
|
5c1036b72f | |
|
fb2b8d933b | |
|
5a339cbee6 | |
|
d43c61d6ee | |
|
6bafd06fc0 | |
|
104de66a66 | |
|
abf132ec99 | |
|
807259b2a5 | |
|
38d61c056b | |
|
970ca46ef6 | |
|
7d50b12a4f | |
|
2237293eda | |
|
64c46e6be9 | |
|
54f6612048 | |
|
f4d8a4803b | |
|
81598fc57c | |
|
c9d7b9f4da | |
|
c00a4b65bb | |
|
f9a0fea9ab | |
|
6900fdb481 | |
|
396fd122a8 | |
|
0fe451029d | |
|
8f36380e21 | |
|
3493003588 | |
|
85627fb8ad | |
|
90ec1e9b09 | |
|
eefbb1c09b | |
|
39794e36a2 | |
|
aaa1081ddf | |
|
77291ba724 | |
|
ae2713dbd0 | |
|
1cdadc9fc4 | |
|
b9678313bf | |
|
e17e2c55a5 | |
|
788e043269 | |
|
da589807ef | |
|
4951b8720d | |
|
b783234794 |
30
README.md
30
README.md
|
@ -1,8 +1,5 @@
|
||||||
# Perfin
|
# Perfin
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
A personal accounting desktop app to track your finances using an approachable
|
A personal accounting desktop app to track your finances using an approachable
|
||||||
interface and interoperable file formats for maximum compatibility.
|
interface and interoperable file formats for maximum compatibility.
|
||||||
|
|
||||||
|
@ -37,3 +34,30 @@ to set the version everywhere that it needs to be.
|
||||||
|
|
||||||
Once that's done, the workflow will start, and you should see a release appear
|
Once that's done, the workflow will start, and you should see a release appear
|
||||||
in the next few minutes.
|
in the next few minutes.
|
||||||
|
|
||||||
|
## Migration Procedure
|
||||||
|
|
||||||
|
Because this application relies on a structured relational database schema,
|
||||||
|
changes to the schema must be handled with care to avoid destroying users' data.
|
||||||
|
Specifically, when changes are made to the schema, a *migration* must be defined
|
||||||
|
which provides instructions for Perfin to safely apply changes to an old schema.
|
||||||
|
|
||||||
|
The database schema is versioned using whole-number versions (1, 2, 3, ...), and
|
||||||
|
a migration is defined for each transition from version to version, such that
|
||||||
|
any older version can be incrementally upgraded, step by step, to the latest
|
||||||
|
schema version.
|
||||||
|
|
||||||
|
Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
|
||||||
|
When the app loads a profile, it'll check that profile's schema version by
|
||||||
|
reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
|
||||||
|
the profile's schema version is **less than** the current, Perfin will
|
||||||
|
ask the user if they want to upgrade. If the profile's schema version is
|
||||||
|
**greater than** the current, Perfin will tell the user that it can't load a
|
||||||
|
schema from a newer version, and will prompt the user to upgrade.
|
||||||
|
|
||||||
|
### Writing a Migration
|
||||||
|
|
||||||
|
1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
|
||||||
|
2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
|
||||||
|
3. Increment the schema version defined in `JdbcDataSourceFactory`.
|
||||||
|
4. Test the migration yourself on a profile with data.
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 96 KiB |
4
pom.xml
4
pom.xml
|
@ -6,13 +6,13 @@
|
||||||
|
|
||||||
<groupId>com.andrewlalis</groupId>
|
<groupId>com.andrewlalis</groupId>
|
||||||
<artifactId>perfin</artifactId>
|
<artifactId>perfin</artifactId>
|
||||||
<version>1.4.0</version>
|
<version>1.19.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<javafx.version>21.0.1</javafx.version>
|
<javafx.version>21.0.2</javafx.version>
|
||||||
<project.main-class>com.andrewlalis.perfin.PerfinApp</project.main-class>
|
<project.main-class>com.andrewlalis.perfin.PerfinApp</project.main-class>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
|
||||||
|
|
||||||
jpackage \
|
jpackage \
|
||||||
--name "Perfin" \
|
--name "Perfin" \
|
||||||
--app-version "1.4.0" \
|
--app-version "1.19.0" \
|
||||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
|
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
|
||||||
--icon design/perfin-logo_256.png \
|
--icon design/perfin-logo_256.png \
|
||||||
--vendor "Andrew Lalis" \
|
--vendor "Andrew Lalis" \
|
||||||
|
|
|
@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
|
||||||
|
|
||||||
jpackage `
|
jpackage `
|
||||||
--name "Perfin" `
|
--name "Perfin" `
|
||||||
--app-version "1.4.0" `
|
--app-version "1.19.0" `
|
||||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
|
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
|
||||||
--icon design\perfin-logo_256.ico `
|
--icon design\perfin-logo_256.ico `
|
||||||
--vendor "Andrew Lalis" `
|
--vendor "Andrew Lalis" `
|
||||||
|
|
|
@ -3,7 +3,9 @@ package com.andrewlalis.perfin;
|
||||||
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
|
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
|
||||||
import com.andrewlalis.javafx_scene_router.SceneRouter;
|
import com.andrewlalis.javafx_scene_router.SceneRouter;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.ProfileLoader;
|
||||||
import com.andrewlalis.perfin.view.ImageCache;
|
import com.andrewlalis.perfin.view.ImageCache;
|
||||||
import com.andrewlalis.perfin.view.SceneUtil;
|
import com.andrewlalis.perfin.view.SceneUtil;
|
||||||
import com.andrewlalis.perfin.view.StartupSplashScreen;
|
import com.andrewlalis.perfin.view.StartupSplashScreen;
|
||||||
|
@ -28,7 +30,10 @@ import java.util.function.Consumer;
|
||||||
public class PerfinApp extends Application {
|
public class PerfinApp extends Application {
|
||||||
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
||||||
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
||||||
|
/** The singleton instance of the application. */
|
||||||
public static PerfinApp instance;
|
public static PerfinApp instance;
|
||||||
|
/** The singleton profile loader for the application. */
|
||||||
|
public static ProfileLoader profileLoader;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The router that's used for navigating between different "pages" in the application.
|
* The router that's used for navigating between different "pages" in the application.
|
||||||
|
@ -48,13 +53,14 @@ public class PerfinApp extends Application {
|
||||||
@Override
|
@Override
|
||||||
public void start(Stage stage) {
|
public void start(Stage stage) {
|
||||||
instance = this;
|
instance = this;
|
||||||
|
profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
|
||||||
loadFonts();
|
loadFonts();
|
||||||
var splashScreen = new StartupSplashScreen(List.of(
|
var splashScreen = new StartupSplashScreen(List.of(
|
||||||
PerfinApp::defineRoutes,
|
PerfinApp::defineRoutes,
|
||||||
PerfinApp::initAppDir,
|
PerfinApp::initAppDir,
|
||||||
c -> initMainScreen(stage, c),
|
c -> initMainScreen(stage, c),
|
||||||
PerfinApp::loadLastUsedProfile
|
PerfinApp::loadLastUsedProfile
|
||||||
));
|
), false);
|
||||||
splashScreen.showAndWait();
|
splashScreen.showAndWait();
|
||||||
if (splashScreen.isStartupSuccessful()) {
|
if (splashScreen.isStartupSuccessful()) {
|
||||||
stage.show();
|
stage.show();
|
||||||
|
@ -62,24 +68,33 @@ public class PerfinApp extends Application {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Part of the app's startup, where the main scene is initialized.
|
||||||
|
* @param stage The JavaFX stage where the scene will be placed.
|
||||||
|
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||||
|
*/
|
||||||
private void initMainScreen(Stage stage, Consumer<String> msgConsumer) {
|
private void initMainScreen(Stage stage, Consumer<String> msgConsumer) {
|
||||||
msgConsumer.accept("Initializing main screen.");
|
msgConsumer.accept("Initializing main screen.");
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
stage.hide();
|
stage.hide();
|
||||||
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
|
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
|
||||||
mainViewScene.getStylesheets().addAll(
|
SceneUtil.addStylesheets(mainViewScene, "/style/base.css");
|
||||||
PerfinApp.class.getResource("/style/base.css").toExternalForm()
|
|
||||||
);
|
|
||||||
stage.setScene(mainViewScene);
|
stage.setScene(mainViewScene);
|
||||||
stage.setTitle("Perfin");
|
stage.setTitle("Perfin");
|
||||||
stage.getIcons().add(ImageCache.getLogo256());
|
stage.getIcons().add(ImageCache.getLogo256());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Part of the app's startup, where all the app's routes to various views
|
||||||
|
* are defined.
|
||||||
|
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||||
|
*/
|
||||||
private static void defineRoutes(Consumer<String> msgConsumer) {
|
private static void defineRoutes(Consumer<String> msgConsumer) {
|
||||||
msgConsumer.accept("Initializing application views.");
|
msgConsumer.accept("Initializing application views.");
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
// App pages.
|
// App pages.
|
||||||
|
router.map("dashboard", PerfinApp.class.getResource("/dashboard.fxml"));
|
||||||
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
|
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
|
||||||
router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
|
router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
|
||||||
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));
|
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));
|
||||||
|
@ -87,6 +102,12 @@ public class PerfinApp extends Application {
|
||||||
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
|
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
|
||||||
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
|
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
|
||||||
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
|
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
|
||||||
|
router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
|
||||||
|
router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
|
||||||
|
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
|
||||||
|
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
|
||||||
|
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
|
||||||
|
router.map("sql-console", PerfinApp.class.getResource("/sql-console-view.fxml"));
|
||||||
|
|
||||||
// Help pages.
|
// Help pages.
|
||||||
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
|
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
|
||||||
|
@ -96,14 +117,21 @@ public class PerfinApp extends Application {
|
||||||
helpRouter.map("adding-a-transaction", PerfinApp.class.getResource("/help-pages/adding-a-transaction.fxml"));
|
helpRouter.map("adding-a-transaction", PerfinApp.class.getResource("/help-pages/adding-a-transaction.fxml"));
|
||||||
helpRouter.map("profiles", PerfinApp.class.getResource("/help-pages/profiles.fxml"));
|
helpRouter.map("profiles", PerfinApp.class.getResource("/help-pages/profiles.fxml"));
|
||||||
helpRouter.map("about", PerfinApp.class.getResource("/help-pages/about.fxml"));
|
helpRouter.map("about", PerfinApp.class.getResource("/help-pages/about.fxml"));
|
||||||
|
helpRouter.map("sql-console", PerfinApp.class.getResource("/help-pages/sql-console.fxml"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part of the app's startup which ensures that the main directory exists.
|
||||||
|
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||||
|
* @throws Exception If file operations fail.
|
||||||
|
*/
|
||||||
private static void initAppDir(Consumer<String> msgConsumer) throws Exception {
|
private static void initAppDir(Consumer<String> msgConsumer) throws Exception {
|
||||||
msgConsumer.accept("Validating application files.");
|
msgConsumer.accept("Validating application files.");
|
||||||
if (Files.notExists(APP_DIR)) {
|
if (Files.notExists(APP_DIR)) {
|
||||||
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
|
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
|
||||||
Files.createDirectory(APP_DIR);
|
Files.createDirectory(APP_DIR);
|
||||||
|
Files.createDirectory(Profile.getProfilesDir());
|
||||||
} else if (Files.exists(APP_DIR) && Files.isRegularFile(APP_DIR)) {
|
} else if (Files.exists(APP_DIR) && Files.isRegularFile(APP_DIR)) {
|
||||||
msgConsumer.accept(APP_DIR + " is a file, when it should be a directory. Deleting it and creating new directory.");
|
msgConsumer.accept(APP_DIR + " is a file, when it should be a directory. Deleting it and creating new directory.");
|
||||||
Files.delete(APP_DIR);
|
Files.delete(APP_DIR);
|
||||||
|
@ -111,16 +139,27 @@ public class PerfinApp extends Application {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The final part of the app's startup sequence, where the last profile is
|
||||||
|
* loaded and set as the current profile. Calling `Profile.setCurrent`
|
||||||
|
* triggers many components to refresh their data for the current profile.
|
||||||
|
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||||
|
* @throws Exception If the profile could not be loaded for some reason.
|
||||||
|
*/
|
||||||
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
|
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
|
||||||
msgConsumer.accept("Loading the most recent profile.");
|
String lastProfile = ProfileLoader.getLastProfile();
|
||||||
|
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
|
||||||
try {
|
try {
|
||||||
Profile.loadLast();
|
Profile.setCurrent(profileLoader.load(lastProfile));
|
||||||
} catch (ProfileLoadException e) {
|
} catch (ProfileLoadException e) {
|
||||||
msgConsumer.accept("Failed to load the profile: " + e.getMessage());
|
msgConsumer.accept("Failed to load the profile: " + e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all application fonts from the bundled resource files.
|
||||||
|
*/
|
||||||
private static void loadFonts() {
|
private static void loadFonts() {
|
||||||
List<String> fontResources = List.of(
|
List<String> fontResources = List.of(
|
||||||
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf",
|
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf",
|
||||||
|
|
|
@ -1,94 +1,176 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
import com.andrewlalis.perfin.view.component.AccountHistoryView;
|
||||||
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.Node;
|
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.DatePicker;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class AccountViewController implements RouteSelectionListener {
|
public class AccountViewController implements RouteSelectionListener {
|
||||||
private Account account;
|
private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
|
||||||
|
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
|
||||||
@FXML public Label accountNameLabel;
|
@FXML public Label accountNameLabel;
|
||||||
@FXML public Label accountNumberLabel;
|
@FXML public Label accountNumberLabel;
|
||||||
@FXML public Label accountCurrencyLabel;
|
@FXML public Label accountCurrencyLabel;
|
||||||
@FXML public Label accountCreatedAtLabel;
|
@FXML public Label accountCreatedAtLabel;
|
||||||
@FXML public Label accountBalanceLabel;
|
@FXML public Label accountBalanceLabel;
|
||||||
@FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false);
|
@FXML public PropertiesPane assetValuePane;
|
||||||
|
@FXML public Label latestAssetsValueLabel;
|
||||||
|
@FXML public PropertiesPane creditCardPropertiesPane;
|
||||||
|
@FXML public Label creditLimitLabel;
|
||||||
|
@FXML public PropertiesPane descriptionPane;
|
||||||
|
@FXML public Text accountDescriptionText;
|
||||||
|
|
||||||
@FXML public VBox historyItemsVBox;
|
@FXML public AccountHistoryView accountHistory;
|
||||||
@FXML public Button loadMoreHistoryButton;
|
|
||||||
private LocalDateTime loadHistoryFrom;
|
|
||||||
private final int historyLoadSize = 5;
|
|
||||||
|
|
||||||
@FXML public VBox actionsVBox;
|
@FXML public HBox actionsBox;
|
||||||
|
|
||||||
|
@FXML public DatePicker balanceCheckerDatePicker;
|
||||||
|
@FXML public Button balanceCheckerButton;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
actionsVBox.getChildren().forEach(node -> {
|
titleLabel.textProperty().bind(accountProperty.map(a -> "Account #" + a.id));
|
||||||
|
accountNameLabel.textProperty().bind(accountProperty.map(Account::getName));
|
||||||
|
accountNumberLabel.textProperty().bind(accountProperty.map(Account::getAccountNumber));
|
||||||
|
accountCurrencyLabel.textProperty().bind(accountProperty.map(a -> a.getCurrency().getDisplayName()));
|
||||||
|
accountCreatedAtLabel.textProperty().bind(accountProperty.map(a -> DateUtil.formatUTCAsLocalWithZone(a.getCreatedAt())));
|
||||||
|
accountDescriptionText.textProperty().bind(accountProperty.map(Account::getDescription));
|
||||||
|
var hasDescription = accountProperty.map(a -> a.getDescription() != null);
|
||||||
|
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
|
||||||
|
accountBalanceLabel.textProperty().bind(balanceTextProperty);
|
||||||
|
|
||||||
|
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
|
||||||
|
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
|
||||||
|
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty);
|
||||||
|
|
||||||
|
var isCreditCardAccount = accountProperty.map(a -> a.getType() == AccountType.CREDIT_CARD);
|
||||||
|
BindingUtil.bindManagedAndVisible(creditCardPropertiesPane, isCreditCardAccount);
|
||||||
|
creditLimitLabel.textProperty().bind(creditLimitTextProperty);
|
||||||
|
|
||||||
|
actionsBox.getChildren().forEach(node -> {
|
||||||
Button button = (Button) node;
|
Button button = (Button) node;
|
||||||
BooleanExpression buttonActive = accountArchivedProperty;
|
ObservableValue<Boolean> buttonDisabled = accountArchived;
|
||||||
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
||||||
buttonActive = buttonActive.not();
|
buttonDisabled = BooleanExpression.booleanExpression(buttonDisabled).not();
|
||||||
}
|
}
|
||||||
button.disableProperty().bind(buttonActive);
|
if (button.getText().equalsIgnoreCase("Record Asset Value")) {
|
||||||
|
buttonDisabled = BooleanExpression.booleanExpression(
|
||||||
|
accountProperty.map(Account::getType)
|
||||||
|
.map(t -> !t.equals(AccountType.BROKERAGE))
|
||||||
|
).or(BooleanExpression.booleanExpression(accountArchived));
|
||||||
|
}
|
||||||
|
button.disableProperty().bind(buttonDisabled);
|
||||||
button.managedProperty().bind(button.visibleProperty());
|
button.managedProperty().bind(button.visibleProperty());
|
||||||
button.visibleProperty().bind(button.disableProperty().not());
|
button.visibleProperty().bind(button.disableProperty().not());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var datePickerValid = new ValidationApplier<>(new PredicateValidator<LocalDate>()
|
||||||
|
.addPredicate(date -> date.isBefore(LocalDate.now()), "Date must be in the past.")
|
||||||
|
).attach(balanceCheckerDatePicker, balanceCheckerDatePicker.valueProperty());
|
||||||
|
balanceCheckerButton.disableProperty().bind(datePickerValid.not());
|
||||||
|
balanceCheckerButton.setOnAction(event -> {
|
||||||
|
LocalDate date = balanceCheckerDatePicker.getValue();
|
||||||
|
final Instant timestamp = date.atStartOfDay(ZoneId.systemDefault())
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toInstant();
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
AccountRepository.class,
|
||||||
|
repo -> repo.deriveCashBalance(getAccount().id, timestamp)
|
||||||
|
).thenAccept(balance -> Platform.runLater(() -> {
|
||||||
|
String msg = String.format(
|
||||||
|
"Your balance as of %s is %s, according to Perfin's data.",
|
||||||
|
date,
|
||||||
|
CurrencyUtil.formatMoney(new MoneyValue(balance, getAccount().getCurrency()))
|
||||||
|
);
|
||||||
|
Popups.message(balanceCheckerButton, msg);
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
account = (Account) context;
|
accountHistory.clear();
|
||||||
accountArchivedProperty.set(account.isArchived());
|
balanceTextProperty.set(null);
|
||||||
titleLabel.setText("Account #" + account.id);
|
assetValueTextProperty.set(null);
|
||||||
accountNameLabel.setText(account.getName());
|
if (context instanceof Account account) {
|
||||||
accountNumberLabel.setText(account.getAccountNumber());
|
this.accountProperty.set(account);
|
||||||
accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
|
accountHistory.setAccountId(account.id);
|
||||||
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
|
accountHistory.loadMoreHistory();
|
||||||
Profile.getCurrent().getDataSource().getAccountBalanceText(account)
|
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
||||||
.thenAccept(accountBalanceLabel::setText);
|
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
|
||||||
|
if (account.getType() == AccountType.BROKERAGE) {
|
||||||
reloadHistory();
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
}
|
AccountRepository.class,
|
||||||
|
repo -> repo.getNearestAssetValue(account.id)
|
||||||
public void reloadHistory() {
|
).thenApply(value -> CurrencyUtil.formatMoney(new MoneyValue(value, account.getCurrency())))
|
||||||
loadHistoryFrom = DateUtil.nowAsUTC();
|
.thenAccept(text -> Platform.runLater(() -> assetValueTextProperty.set(text)));
|
||||||
historyItemsVBox.getChildren().clear();
|
} else if (account.getType() == AccountType.CREDIT_CARD) {
|
||||||
loadMoreHistoryButton.setDisable(false);
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
loadMoreHistory();
|
AccountRepository.class,
|
||||||
|
repo -> repo.getCreditCardProperties(account.id)
|
||||||
|
).thenAccept(props -> Platform.runLater(() -> {
|
||||||
|
if (props == null) {
|
||||||
|
creditLimitTextProperty.set("No credit card info.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.creditLimit() == null) {
|
||||||
|
creditLimitTextProperty.set("No credit limit set.");
|
||||||
|
} else {
|
||||||
|
MoneyValue money = new MoneyValue(props.creditLimit(), account.getCurrency());
|
||||||
|
creditLimitTextProperty.set(CurrencyUtil.formatMoney(money));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void goToEditPage() {
|
public void goToEditPage() {
|
||||||
router.navigate("edit-account", account);
|
router.navigate("edit-account", getAccount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void goToCreateBalanceRecord() {
|
@FXML public void goToCreateBalanceRecord() {
|
||||||
router.navigate("create-balance-record", account);
|
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.CASH));
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void goToCreateAssetRecord() {
|
||||||
|
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.ASSETS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void archiveAccount() {
|
public void archiveAccount() {
|
||||||
boolean confirmResult = Popups.confirm(
|
boolean confirmResult = Popups.confirm(
|
||||||
|
titleLabel,
|
||||||
"Are you sure you want to archive this account? It will no " +
|
"Are you sure you want to archive this account? It will no " +
|
||||||
"longer show up in the app normally, and you won't be " +
|
"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 add new transactions to it. You'll still be " +
|
||||||
|
@ -96,18 +178,19 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
"later if you need to."
|
"later if you need to."
|
||||||
);
|
);
|
||||||
if (confirmResult) {
|
if (confirmResult) {
|
||||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(getAccount().id));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void unarchiveAccount() {
|
@FXML public void unarchiveAccount() {
|
||||||
boolean confirm = Popups.confirm(
|
boolean confirm = Popups.confirm(
|
||||||
|
titleLabel,
|
||||||
"Are you sure you want to restore this account from its archived " +
|
"Are you sure you want to restore this account from its archived " +
|
||||||
"status?"
|
"status?"
|
||||||
);
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(getAccount().id));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -115,6 +198,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
@FXML
|
@FXML
|
||||||
public void deleteAccount() {
|
public void deleteAccount() {
|
||||||
boolean confirm = Popups.confirm(
|
boolean confirm = Popups.confirm(
|
||||||
|
titleLabel,
|
||||||
"Are you sure you want to permanently delete this account and " +
|
"Are you sure you want to permanently delete this account and " +
|
||||||
"all data directly associated with it? This cannot be " +
|
"all data directly associated with it? This cannot be " +
|
||||||
"undone; deleted accounts are not recoverable at all. " +
|
"undone; deleted accounts are not recoverable at all. " +
|
||||||
|
@ -122,27 +206,12 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
"want to hide it."
|
"want to hide it."
|
||||||
);
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(getAccount()));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void loadMoreHistory() {
|
private Account getAccount() {
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> {
|
return accountProperty.get();
|
||||||
List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount(
|
|
||||||
account.id,
|
|
||||||
loadHistoryFrom,
|
|
||||||
historyLoadSize
|
|
||||||
);
|
|
||||||
if (historyItems.size() < historyLoadSize) {
|
|
||||||
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
|
|
||||||
} else {
|
|
||||||
loadHistoryFrom = historyItems.getLast().getTimestamp();
|
|
||||||
}
|
|
||||||
List<? extends Node> nodes = historyItems.stream()
|
|
||||||
.map(item -> AccountHistoryItemTile.forItem(item, repo, this))
|
|
||||||
.toList();
|
|
||||||
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.component.AccountTile;
|
import com.andrewlalis.perfin.view.component.AccountTile;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -49,7 +48,7 @@ public class AccountsViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
public void refreshAccounts() {
|
public void refreshAccounts() {
|
||||||
Profile.whenLoaded(profile -> {
|
Profile.whenLoaded(profile -> {
|
||||||
profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
List<Account> accounts = repo.findAllOrderedByRecentHistory();
|
List<Account> accounts = repo.findAllOrderedByRecentHistory();
|
||||||
Platform.runLater(() -> accountsPane.getChildren()
|
Platform.runLater(() -> accountsPane.getChildren()
|
||||||
.setAll(accounts.stream()
|
.setAll(accounts.stream()
|
||||||
|
@ -57,15 +56,9 @@ public class AccountsViewController implements RouteSelectionListener {
|
||||||
.toList()
|
.toList()
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
// Compute grand totals!
|
profile.dataSource().getCombinedAccountBalances()
|
||||||
Thread.ofVirtual().start(() -> {
|
.thenApply(CurrencyUtil::formatMoneyValues)
|
||||||
var totals = profile.getDataSource().getCombinedAccountBalances();
|
.thenAccept(s -> Platform.runLater(() -> totalLabel.setText("Totals: " + s)));
|
||||||
StringBuilder sb = new StringBuilder("Totals: ");
|
|
||||||
for (var entry : totals.entrySet()) {
|
|
||||||
sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
|
|
||||||
}
|
|
||||||
Platform.runLater(() -> totalLabel.setText(sb.toString().strip()));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
|
||||||
|
@FXML public Label typeLabel;
|
||||||
@FXML public Label timestampLabel;
|
@FXML public Label timestampLabel;
|
||||||
@FXML public Label balanceLabel;
|
@FXML public Label balanceLabel;
|
||||||
@FXML public Label currencyLabel;
|
@FXML public Label currencyLabel;
|
||||||
|
@ -38,19 +39,23 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
||||||
this.balanceRecord = (BalanceRecord) context;
|
this.balanceRecord = (BalanceRecord) context;
|
||||||
if (balanceRecord == null) return;
|
if (balanceRecord == null) return;
|
||||||
titleLabel.setText("Balance Record #" + balanceRecord.id);
|
titleLabel.setText("Balance Record #" + balanceRecord.id);
|
||||||
|
typeLabel.setText(balanceRecord.getType().toString());
|
||||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
||||||
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
||||||
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
|
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
|
||||||
List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
|
List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
|
||||||
Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
|
Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void delete() {
|
@FXML public void delete() {
|
||||||
boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin.");
|
boolean confirm = Popups.confirm(
|
||||||
|
titleLabel,
|
||||||
|
"Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."
|
||||||
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
|
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
|
||||||
router.navigateBackAndClear();
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import com.andrewlalis.perfin.view.component.CategoryTile;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class CategoriesViewController implements RouteSelectionListener {
|
||||||
|
@FXML public VBox categoriesVBox;
|
||||||
|
private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
refreshCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void addCategory() {
|
||||||
|
router.navigate("edit-category");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshCategories() {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
TransactionCategoryRepository::findTree
|
||||||
|
).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void addDefaultCategories() {
|
||||||
|
boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
|
||||||
|
if (!confirm) return;
|
||||||
|
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||||
|
try (var conn = ds.getConnection()) {
|
||||||
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
try {
|
||||||
|
new JdbcDataSourceFactory().insertDefaultCategories(conn);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
refreshCategories();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Popups.error(categoriesVBox, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,11 +6,13 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -23,11 +25,18 @@ import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the page where users can create a balance record for an
|
||||||
|
* account.
|
||||||
|
*/
|
||||||
public class CreateBalanceRecordController implements RouteSelectionListener {
|
public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
|
public record RouteContext (Account account, BalanceRecordType type) {}
|
||||||
|
|
||||||
@FXML public TextField timestampField;
|
@FXML public TextField timestampField;
|
||||||
@FXML public TextField balanceField;
|
@FXML public TextField balanceField;
|
||||||
@FXML public Label balanceWarningLabel;
|
@FXML public Label balanceWarningLabel;
|
||||||
|
@ -37,9 +46,10 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
private Account account;
|
private Account account;
|
||||||
|
private BalanceRecordType type = BalanceRecordType.CASH;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
var timestampValid = new ValidationApplier<String>(input -> {
|
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
|
||||||
try {
|
try {
|
||||||
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
|
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
|
||||||
return ValidationResult.valid();
|
return ValidationResult.valid();
|
||||||
|
@ -55,16 +65,17 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
||||||
balanceWarningLabel.visibleProperty().set(false);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
if (!balanceValidator.validate(newValue).isValid()) {
|
if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get() || type != BalanceRecordType.CASH) {
|
||||||
balanceWarningLabel.visibleProperty().set(false);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BigDecimal reportedBalance = new BigDecimal(newValue);
|
BigDecimal reportedBalance = new BigDecimal(newValue);
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||||
BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
|
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
|
||||||
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
!reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
|
BigDecimal derivedBalance = repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC));
|
||||||
));
|
boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance);
|
||||||
|
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -74,14 +85,19 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
this.account = (Account) context;
|
RouteContext ctx = (RouteContext) context;
|
||||||
|
this.account = ctx.account();
|
||||||
|
this.type = ctx.type();
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
balanceField.setText(null);
|
||||||
BigDecimal value = repo.deriveCurrentBalance(account.id);
|
if (ctx.type() == BalanceRecordType.CASH) {
|
||||||
Platform.runLater(() -> balanceField.setText(
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
BigDecimal value = repo.deriveCurrentCashBalance(account.id);
|
||||||
));
|
Platform.runLater(() -> balanceField.setText(
|
||||||
});
|
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
attachmentSelectionArea.clear();
|
attachmentSelectionArea.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,16 +105,26 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||||
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
||||||
|
|
||||||
boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
|
String valueNoun = switch (type) {
|
||||||
|
case CASH -> "cash balance";
|
||||||
|
case ASSETS -> "asset value";
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the %s of account\n%s\nas %s,\nas of %s?".formatted(
|
||||||
|
valueNoun,
|
||||||
account.getShortName(),
|
account.getShortName(),
|
||||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
||||||
));
|
));
|
||||||
if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
|
if (
|
||||||
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
confirm &&
|
||||||
|
(type != BalanceRecordType.CASH || confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp)))
|
||||||
|
) {
|
||||||
|
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||||
repo.insert(
|
repo.insert(
|
||||||
DateUtil.localToUTC(localTimestamp),
|
DateUtil.localToUTC(localTimestamp),
|
||||||
account.id,
|
account.id,
|
||||||
|
type,
|
||||||
reportedBalance,
|
reportedBalance,
|
||||||
account.getCurrency(),
|
account.getCurrency(),
|
||||||
attachmentSelectionArea.getSelectedPaths()
|
attachmentSelectionArea.getSelectedPaths()
|
||||||
|
@ -112,17 +138,17 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
router.navigateBackAndClear();
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
|
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
|
||||||
BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo(
|
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
||||||
AccountRepository.class,
|
AccountRepository.class,
|
||||||
repo -> repo.deriveCurrentBalance(account.id)
|
repo -> repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC))
|
||||||
);
|
);
|
||||||
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
|
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
|
||||||
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
|
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
|
||||||
CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
|
CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||||
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
|
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
|
||||||
);
|
);
|
||||||
return Popups.confirm(msg);
|
return Popups.confirm(timestampField, msg);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.view.component.module.TotalAssetsGraphModule;
|
||||||
|
import com.andrewlalis.perfin.view.component.module.*;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.geometry.Bounds;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.layout.FlowPane;
|
||||||
|
|
||||||
|
public class DashboardController implements RouteSelectionListener {
|
||||||
|
@FXML public ScrollPane modulesScrollPane;
|
||||||
|
@FXML public FlowPane modulesFlowPane;
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
|
||||||
|
modulesFlowPane.minWidthProperty().bind(viewportWidth);
|
||||||
|
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
|
||||||
|
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
|
||||||
|
|
||||||
|
var accountsModule = new AccountsModule(modulesFlowPane);
|
||||||
|
accountsModule.columnsProperty.set(2);
|
||||||
|
|
||||||
|
var transactionsModule = new RecentTransactionsModule(modulesFlowPane);
|
||||||
|
transactionsModule.columnsProperty.set(2);
|
||||||
|
|
||||||
|
var m3 = new SpendingCategoryChartModule(modulesFlowPane);
|
||||||
|
m3.columnsProperty.set(2);
|
||||||
|
|
||||||
|
var m4 = new VendorSpendChartModule(modulesFlowPane);
|
||||||
|
m4.columnsProperty.set(2);
|
||||||
|
|
||||||
|
var m5 = new TotalAssetsGraphModule(modulesFlowPane);
|
||||||
|
m5.columnsProperty.set(1);
|
||||||
|
|
||||||
|
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4, m5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
Profile.whenLoaded(profile -> {
|
||||||
|
for (var child : modulesFlowPane.getChildren()) {
|
||||||
|
DashboardModule module = (DashboardModule) child;
|
||||||
|
module.refreshContents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
@ -31,20 +33,15 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
private Account account;
|
private Account account;
|
||||||
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
|
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
|
||||||
|
|
||||||
@FXML
|
@FXML public Label titleLabel;
|
||||||
public Label titleLabel;
|
@FXML public TextField accountNameField;
|
||||||
@FXML
|
@FXML public TextField accountNumberField;
|
||||||
public TextField accountNameField;
|
@FXML public ComboBox<Currency> accountCurrencyComboBox;
|
||||||
@FXML
|
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
|
||||||
public TextField accountNumberField;
|
@FXML public TextField creditLimitField;
|
||||||
@FXML
|
@FXML public TextArea descriptionField;
|
||||||
public ComboBox<Currency> accountCurrencyComboBox;
|
@FXML public PropertiesPane initialBalanceContent;
|
||||||
@FXML
|
@FXML public TextField initialBalanceField;
|
||||||
public ChoiceBox<AccountType> accountTypeChoiceBox;
|
|
||||||
@FXML
|
|
||||||
public PropertiesPane initialBalanceContent;
|
|
||||||
@FXML
|
|
||||||
public TextField initialBalanceField;
|
|
||||||
|
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
|
@ -52,21 +49,37 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
public void initialize() {
|
public void initialize() {
|
||||||
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name should not be empty.")
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name should not be empty.")
|
||||||
.addPredicate(s -> s.length() <= 63, "Name is too long.")
|
.addPredicate(s -> s.strip().length() <= 63, "Name is too long.")
|
||||||
).attachToTextField(accountNameField);
|
).attachToTextField(accountNameField);
|
||||||
|
|
||||||
var numberValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var numberValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Account number should not be empty.")
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Account number should not be empty.")
|
||||||
.addPredicate(s -> s.length() <= 255, "Account number is too long.")
|
.addPredicate(s -> s.strip().length() <= 255, "Account number is too long.")
|
||||||
.addPredicate(s -> s.matches("\\d+"), "Account number should contain only numeric digits.")
|
|
||||||
).attachToTextField(accountNumberField);
|
).attachToTextField(accountNumberField);
|
||||||
|
|
||||||
var balanceValid = new ValidationApplier<>(
|
var balanceValid = new ValidationApplier<>(
|
||||||
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), false, false)
|
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
|
||||||
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
|
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
|
||||||
|
|
||||||
|
var isEditingCreditCardAccount = accountTypeChoiceBox.valueProperty().isEqualTo(AccountType.CREDIT_CARD);
|
||||||
|
BindingUtil.bindManagedAndVisible(creditLimitField, isEditingCreditCardAccount);
|
||||||
|
var creditLimitValid = new ValidationApplier<>(new CurrencyAmountValidator(
|
||||||
|
() -> accountCurrencyComboBox.getValue(),
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)).validatedInitially().attachToTextField(creditLimitField)
|
||||||
|
.or(isEditingCreditCardAccount.not());
|
||||||
|
|
||||||
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
|
).attach(descriptionField, descriptionField.textProperty());
|
||||||
|
|
||||||
// Combine validity of all fields for an expression that determines if the whole form is valid.
|
// Combine validity of all fields for an expression that determines if the whole form is valid.
|
||||||
BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid);
|
BooleanExpression formValid = nameValid
|
||||||
|
.and(numberValid)
|
||||||
|
.and(balanceValid.or(creatingNewAccount.not()))
|
||||||
|
.and(descriptionValid)
|
||||||
|
.and(creditLimitValid);
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
|
|
||||||
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
||||||
|
@ -85,6 +98,7 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
accountTypeChoiceBox.getItems().add(AccountType.CHECKING);
|
accountTypeChoiceBox.getItems().add(AccountType.CHECKING);
|
||||||
accountTypeChoiceBox.getItems().add(AccountType.SAVINGS);
|
accountTypeChoiceBox.getItems().add(AccountType.SAVINGS);
|
||||||
accountTypeChoiceBox.getItems().add(AccountType.CREDIT_CARD);
|
accountTypeChoiceBox.getItems().add(AccountType.CREDIT_CARD);
|
||||||
|
accountTypeChoiceBox.getItems().add(AccountType.BROKERAGE);
|
||||||
accountTypeChoiceBox.getSelectionModel().select(AccountType.CHECKING);
|
accountTypeChoiceBox.getSelectionModel().select(AccountType.CHECKING);
|
||||||
|
|
||||||
initialBalanceContent.visibleProperty().bind(creatingNewAccount);
|
initialBalanceContent.visibleProperty().bind(creatingNewAccount);
|
||||||
|
@ -105,40 +119,56 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void save() {
|
public void save() {
|
||||||
|
String name = accountNameField.getText().strip();
|
||||||
|
String number = accountNumberField.getText().strip();
|
||||||
|
AccountType type = accountTypeChoiceBox.getValue();
|
||||||
|
Currency currency = accountCurrencyComboBox.getValue();
|
||||||
|
String description = descriptionField.getText();
|
||||||
|
if (description != null) {
|
||||||
|
description = description.strip();
|
||||||
|
if (description.isBlank()) description = null;
|
||||||
|
}
|
||||||
|
BigDecimal creditLimit = null;
|
||||||
|
if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
|
||||||
|
creditLimit = new BigDecimal(creditLimitField.getText());
|
||||||
|
}
|
||||||
try (
|
try (
|
||||||
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
|
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
|
||||||
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
|
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
|
||||||
) {
|
) {
|
||||||
if (creatingNewAccount.get()) {
|
if (creatingNewAccount.get()) {
|
||||||
String name = accountNameField.getText().strip();
|
|
||||||
String number = accountNumberField.getText().strip();
|
|
||||||
AccountType type = accountTypeChoiceBox.getValue();
|
|
||||||
Currency currency = accountCurrencyComboBox.getValue();
|
|
||||||
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
||||||
List<Path> attachments = Collections.emptyList();
|
List<Path> attachments = Collections.emptyList();
|
||||||
|
|
||||||
boolean success = Popups.confirm("Are you sure you want to create this account?");
|
String prompt = String.format(
|
||||||
|
"Are you sure you want to create this account?\nName: %s\nNumber: %s\nType: %s\nInitial Balance: %s",
|
||||||
|
name,
|
||||||
|
number,
|
||||||
|
type.toString(),
|
||||||
|
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(initialBalance, currency))
|
||||||
|
);
|
||||||
|
boolean success = Popups.confirm(accountNameField, prompt);
|
||||||
if (success) {
|
if (success) {
|
||||||
long id = accountRepo.insert(type, number, name, currency);
|
long id = accountRepo.insert(type, number, name, currency, description);
|
||||||
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
|
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
|
||||||
|
if (type == AccountType.CREDIT_CARD && creditLimit != null) {
|
||||||
|
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
|
||||||
|
}
|
||||||
// Once we create the new account, go to the account.
|
// Once we create the new account, go to the account.
|
||||||
Account newAccount = accountRepo.findById(id).orElseThrow();
|
Account newAccount = accountRepo.findById(id).orElseThrow();
|
||||||
router.replace("account", newAccount);
|
router.replace("account", newAccount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Updating account {}", account.id);
|
accountRepo.update(account.id, type, number, name, currency, description);
|
||||||
account.setName(accountNameField.getText().strip());
|
if (type == AccountType.CREDIT_CARD) {
|
||||||
account.setAccountNumber(accountNumberField.getText().strip());
|
accountRepo.saveCreditCardProperties(new CreditCardProperties(account.id, creditLimit));
|
||||||
account.setType(accountTypeChoiceBox.getValue());
|
}
|
||||||
account.setCurrency(accountCurrencyComboBox.getValue());
|
|
||||||
accountRepo.update(account);
|
|
||||||
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
|
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
|
||||||
router.replace("account", updatedAccount);
|
router.replace("account", updatedAccount);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to save (or update) account " + account.id, e);
|
log.error("Failed to save (or update) account " + account.id, e);
|
||||||
Popups.error("Failed to save the account: " + e.getMessage());
|
Popups.error(accountNameField, "Failed to save the account: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,11 +184,29 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
accountTypeChoiceBox.getSelectionModel().selectFirst();
|
accountTypeChoiceBox.getSelectionModel().selectFirst();
|
||||||
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
|
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
|
||||||
initialBalanceField.setText(String.format("%.02f", 0f));
|
initialBalanceField.setText(String.format("%.02f", 0f));
|
||||||
|
descriptionField.setText(null);
|
||||||
|
|
||||||
|
creditLimitField.setText(null);
|
||||||
} else {
|
} else {
|
||||||
accountNameField.setText(account.getName());
|
accountNameField.setText(account.getName());
|
||||||
accountNumberField.setText(account.getAccountNumber());
|
accountNumberField.setText(account.getAccountNumber());
|
||||||
accountTypeChoiceBox.getSelectionModel().select(account.getType());
|
accountTypeChoiceBox.getSelectionModel().select(account.getType());
|
||||||
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
|
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
|
||||||
|
descriptionField.setText(account.getDescription());
|
||||||
|
|
||||||
|
// Fetch the account's credit limit if it's a credit card account.
|
||||||
|
if (account.getType() == AccountType.CREDIT_CARD) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
AccountRepository.class,
|
||||||
|
repo -> repo.getCreditCardProperties(account.id)
|
||||||
|
).thenAccept(props -> Platform.runLater(() -> {
|
||||||
|
if (props != null && props.creditLimit() != null) {
|
||||||
|
creditLimitField.setText(CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(props.creditLimit(), account.getCurrency())));
|
||||||
|
} else {
|
||||||
|
creditLimitField.setText(null);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ColorPicker;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class EditCategoryController implements RouteSelectionListener {
|
||||||
|
public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
|
||||||
|
public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
|
||||||
|
private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
|
||||||
|
|
||||||
|
private TransactionCategory category;
|
||||||
|
private TransactionCategory parent;
|
||||||
|
|
||||||
|
@FXML public TextField nameField;
|
||||||
|
@FXML public ColorPicker colorPicker;
|
||||||
|
|
||||||
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||||
|
.addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
|
||||||
|
.addAsyncPredicate(
|
||||||
|
s -> {
|
||||||
|
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
|
||||||
|
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> {
|
||||||
|
var categoryByName = repo.findByName(s).orElse(null);
|
||||||
|
if (this.category != null) {
|
||||||
|
return this.category.equals(categoryByName) || categoryByName == null;
|
||||||
|
}
|
||||||
|
return categoryByName == null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"Category with this name already exists."
|
||||||
|
)
|
||||||
|
).validatedInitially().attachToTextField(nameField);
|
||||||
|
|
||||||
|
saveButton.disableProperty().bind(nameValid.not());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
this.category = null;
|
||||||
|
this.parent = null;
|
||||||
|
if (context instanceof RouteContext ctx) {
|
||||||
|
switch (ctx) {
|
||||||
|
case CategoryRouteContext(var cat):
|
||||||
|
this.category = cat;
|
||||||
|
nameField.setText(cat.getName());
|
||||||
|
colorPicker.setValue(cat.getColor());
|
||||||
|
break;
|
||||||
|
case AddSubcategoryRouteContext(var par):
|
||||||
|
this.parent = par;
|
||||||
|
nameField.setText(null);
|
||||||
|
colorPicker.setValue(parent.getColor());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nameField.setText(null);
|
||||||
|
colorPicker.setValue(Color.WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void save() {
|
||||||
|
final String name = nameField.getText().strip();
|
||||||
|
final Color color = colorPicker.getValue();
|
||||||
|
if (this.category == null && this.parent == null) {
|
||||||
|
// New top-level category.
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.insert(name, color)
|
||||||
|
);
|
||||||
|
} else if (this.category == null) {
|
||||||
|
// New subcategory.
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.insert(parent.id, name, color)
|
||||||
|
);
|
||||||
|
} else if (this.parent == null) {
|
||||||
|
// Save edits to an existing category.
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.update(category.id, name, color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
router.replace("categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void cancel() {
|
||||||
|
router.navigateBackAndClear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,24 +1,37 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.*;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||||
|
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
|
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.Property;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.*;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.*;
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.input.KeyCode;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -27,13 +40,15 @@ import java.nio.file.Path;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Collections;
|
import java.util.*;
|
||||||
import java.util.Comparator;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.Currency;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the "edit-transaction" view, which is where the user can
|
||||||
|
* create or edit transactions.
|
||||||
|
*/
|
||||||
public class EditTransactionController implements RouteSelectionListener {
|
public class EditTransactionController implements RouteSelectionListener {
|
||||||
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
|
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
|
||||||
|
|
||||||
|
@ -43,12 +58,38 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
@FXML public TextField timestampField;
|
@FXML public TextField timestampField;
|
||||||
@FXML public TextField amountField;
|
@FXML public TextField amountField;
|
||||||
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
||||||
|
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
|
||||||
@FXML public TextArea descriptionField;
|
@FXML public TextArea descriptionField;
|
||||||
|
|
||||||
@FXML public HBox linkedAccountsContainer;
|
@FXML public HBox linkedAccountsContainer;
|
||||||
@FXML public AccountSelectionBox debitAccountSelector;
|
@FXML public AccountSelectionBox debitAccountSelector;
|
||||||
@FXML public AccountSelectionBox creditAccountSelector;
|
@FXML public AccountSelectionBox creditAccountSelector;
|
||||||
|
|
||||||
|
@FXML public ComboBox<String> vendorComboBox;
|
||||||
|
@FXML public Hyperlink vendorsHyperlink;
|
||||||
|
@FXML public CategorySelectionBox categoryComboBox;
|
||||||
|
@FXML public Hyperlink categoriesHyperlink;
|
||||||
|
@FXML public ComboBox<String> tagsComboBox;
|
||||||
|
@FXML public Hyperlink tagsHyperlink;
|
||||||
|
@FXML public Button addTagButton;
|
||||||
|
@FXML public VBox tagsVBox;
|
||||||
|
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
||||||
|
@FXML public TextField lineItemValueField;
|
||||||
|
@FXML public TextField lineItemDescriptionField;
|
||||||
|
@FXML public CategorySelectionBox lineItemCategoryComboBox;
|
||||||
|
@FXML public Button addLineItemButton;
|
||||||
|
@FXML public VBox addLineItemForm;
|
||||||
|
@FXML public Button addLineItemAddButton;
|
||||||
|
@FXML public Button addLineItemCancelButton;
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
|
@FXML public Label lineItemsValueMatchLabel;
|
||||||
|
@FXML public Button lineItemsAmountSyncButton;
|
||||||
|
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
|
||||||
|
private static long tmpLineItemId = -1L;
|
||||||
|
|
||||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||||
|
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
@ -64,39 +105,42 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
return ts != null && ts.isBefore(LocalDateTime.now());
|
return ts != null && ts.isBefore(LocalDateTime.now());
|
||||||
}, "Timestamp cannot be in the future.")
|
}, "Timestamp cannot be in the future.")
|
||||||
).validatedInitially().attachToTextField(timestampField);
|
).validatedInitially().attachToTextField(timestampField);
|
||||||
var amountValid = new ValidationApplier<>(
|
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
|
||||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
@Override
|
||||||
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
|
public ValidationResult validate(String input) {
|
||||||
|
var r = super.validate(input);
|
||||||
|
if (!r.isValid()) return r;
|
||||||
|
// Check that this amount is enough to cover the total of any line items.
|
||||||
|
BigDecimal lineItemsTotal = lineItems.stream().map(TransactionLineItem::getTotalValue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal transactionAmount = new BigDecimal(input);
|
||||||
|
if (transactionAmount.compareTo(lineItemsTotal) < 0) {
|
||||||
|
String msg = String.format(
|
||||||
|
"Amount must be at least %s to account for line items.",
|
||||||
|
CurrencyUtil.formatMoney(new MoneyValue(lineItemsTotal, currencyChoiceBox.getValue()))
|
||||||
|
);
|
||||||
|
return ValidationResult.of(msg);
|
||||||
|
}
|
||||||
|
return ValidationResult.valid();
|
||||||
|
}
|
||||||
|
}).validatedInitially().attachToTextField(
|
||||||
|
amountField,
|
||||||
|
currencyChoiceBox.valueProperty(),
|
||||||
|
new SimpleListProperty<>(lineItems)
|
||||||
|
);
|
||||||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||||
|
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||||
|
initializeTagSelectionUi();
|
||||||
|
initializeLineItemsUi();
|
||||||
|
initializeDuplicateTransactionUi();
|
||||||
|
|
||||||
// Linked accounts will use a property derived from both the debit and credit selections.
|
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||||
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
|
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||||
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
||||||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
|
||||||
var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
|
|
||||||
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
|
|
||||||
.addPredicate(
|
|
||||||
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
|
|
||||||
"The credit and debit accounts cannot be the same."
|
|
||||||
)
|
|
||||||
.addPredicate(
|
|
||||||
accounts -> (
|
|
||||||
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
|
|
||||||
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
|
|
||||||
),
|
|
||||||
"Linked accounts must use the same currency."
|
|
||||||
)
|
|
||||||
.addPredicate(
|
|
||||||
accounts -> (
|
|
||||||
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
|
|
||||||
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
|
|
||||||
),
|
|
||||||
"Linked accounts must not be archived."
|
|
||||||
)
|
|
||||||
).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
|
|
||||||
|
|
||||||
|
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
|
||||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
}
|
}
|
||||||
|
@ -107,11 +151,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
Currency currency = currencyChoiceBox.getValue();
|
Currency currency = currencyChoiceBox.getValue();
|
||||||
String description = getSanitizedDescription();
|
String description = getSanitizedDescription();
|
||||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||||
|
String vendor = vendorComboBox.getValue();
|
||||||
|
String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
|
||||||
|
Set<String> tags = new HashSet<>(selectedTags);
|
||||||
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
||||||
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
||||||
final long idToNavigate;
|
final long idToNavigate;
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
|
idToNavigate = Profile.getCurrent().dataSource().mapRepo(
|
||||||
TransactionRepository.class,
|
TransactionRepository.class,
|
||||||
repo -> repo.insert(
|
repo -> repo.insert(
|
||||||
utcTimestamp,
|
utcTimestamp,
|
||||||
|
@ -119,11 +166,15 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
currency,
|
currency,
|
||||||
description,
|
description,
|
||||||
linkedAccounts,
|
linkedAccounts,
|
||||||
|
vendor,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
lineItems,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Profile.getCurrent().getDataSource().useRepo(
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
TransactionRepository.class,
|
TransactionRepository.class,
|
||||||
repo -> repo.update(
|
repo -> repo.update(
|
||||||
transaction.id,
|
transaction.id,
|
||||||
|
@ -132,6 +183,10 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
currency,
|
currency,
|
||||||
description,
|
description,
|
||||||
linkedAccounts,
|
linkedAccounts,
|
||||||
|
vendor,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
lineItems,
|
||||||
existingAttachments,
|
existingAttachments,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
|
@ -149,6 +204,13 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
transaction = (Transaction) context;
|
transaction = (Transaction) context;
|
||||||
|
|
||||||
|
// Clear some initial fields immediately:
|
||||||
|
tagsComboBox.setValue(null);
|
||||||
|
vendorComboBox.setValue(null);
|
||||||
|
categoryComboBox.select(null);
|
||||||
|
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
titleLabel.setText("Create New Transaction");
|
titleLabel.setText("Create New Transaction");
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
|
@ -163,10 +225,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
|
|
||||||
// Fetch some account-specific data.
|
// Fetch some account-specific data.
|
||||||
container.setDisable(true);
|
container.setDisable(true);
|
||||||
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
Thread.ofVirtual().start(() -> {
|
Thread.ofVirtual().start(() -> {
|
||||||
try (
|
try (
|
||||||
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
|
var accountRepo = ds.getAccountRepository();
|
||||||
var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository()
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
var lineItemRepo = ds.getTransactionLineItemRepository()
|
||||||
) {
|
) {
|
||||||
// First fetch all the data.
|
// First fetch all the data.
|
||||||
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
||||||
|
@ -174,23 +240,53 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
.toList();
|
.toList();
|
||||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||||
final List<Attachment> attachments;
|
final List<Attachment> attachments;
|
||||||
|
final var categoryTreeNodes = categoryRepo.findTree();
|
||||||
|
final List<String> availableTags = transactionRepo.findAllTags();
|
||||||
|
final List<String> tags;
|
||||||
final CreditAndDebitAccounts linkedAccounts;
|
final CreditAndDebitAccounts linkedAccounts;
|
||||||
|
final String vendorName;
|
||||||
|
final TransactionCategory category;
|
||||||
|
final List<TransactionLineItem> existingLineItems;
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
attachments = Collections.emptyList();
|
attachments = Collections.emptyList();
|
||||||
|
tags = Collections.emptyList();
|
||||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||||
|
vendorName = null;
|
||||||
|
category = null;
|
||||||
|
existingLineItems = Collections.emptyList();
|
||||||
} else {
|
} else {
|
||||||
attachments = transactionRepo.findAttachments(transaction.id);
|
attachments = transactionRepo.findAttachments(transaction.id);
|
||||||
|
tags = transactionRepo.findTags(transaction.id);
|
||||||
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
|
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
|
||||||
|
if (transaction.getVendorId() != null) {
|
||||||
|
vendorName = vendorRepo.findById(transaction.getVendorId())
|
||||||
|
.map(TransactionVendor::getName).orElse(null);
|
||||||
|
} else {
|
||||||
|
vendorName = null;
|
||||||
|
}
|
||||||
|
if (transaction.getCategoryId() != null) {
|
||||||
|
category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
|
||||||
|
} else {
|
||||||
|
category = null;
|
||||||
|
}
|
||||||
|
existingLineItems = lineItemRepo.findItems(transaction.id);
|
||||||
}
|
}
|
||||||
|
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||||
// Then make updates to the view.
|
// Then make updates to the view.
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
currencyChoiceBox.getItems().setAll(currencies);
|
||||||
creditAccountSelector.setAccounts(accounts);
|
creditAccountSelector.setAccounts(accounts);
|
||||||
debitAccountSelector.setAccounts(accounts);
|
debitAccountSelector.setAccounts(accounts);
|
||||||
currencyChoiceBox.getItems().setAll(currencies);
|
vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
|
||||||
|
vendorComboBox.setValue(vendorName);
|
||||||
|
categoryComboBox.loadCategories(categoryTreeNodes);
|
||||||
|
categoryComboBox.select(category);
|
||||||
|
tagsComboBox.getItems().setAll(availableTags);
|
||||||
attachmentsSelectionArea.clear();
|
attachmentsSelectionArea.clear();
|
||||||
attachmentsSelectionArea.addAttachments(attachments);
|
attachmentsSelectionArea.addAttachments(attachments);
|
||||||
|
selectedTags.clear();
|
||||||
|
selectedTags.addAll(tags);
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
// TODO: Allow user to select a default currency.
|
|
||||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||||
creditAccountSelector.select(null);
|
creditAccountSelector.select(null);
|
||||||
debitAccountSelector.select(null);
|
debitAccountSelector.select(null);
|
||||||
|
@ -199,15 +295,204 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||||
}
|
}
|
||||||
|
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
|
||||||
|
lineItemCategoryComboBox.select(null);
|
||||||
|
lineItems.setAll(existingLineItems);
|
||||||
container.setDisable(false);
|
container.setDisable(false);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to get repositories.", e);
|
log.error("Failed to get repositories.", e);
|
||||||
Popups.error("Failed to fetch account-specific data: " + e.getMessage());
|
Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
|
||||||
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BooleanExpression initializeLinkedAccountsValidationUi() {
|
||||||
|
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
|
||||||
|
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
|
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
|
return new ValidationApplier<>(getLinkedAccountsValidator())
|
||||||
|
.validatedInitially()
|
||||||
|
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
|
||||||
|
}
|
||||||
|
|
||||||
|
record BasicTransactionInfo(LocalDateTime timestamp, BigDecimal amount, Currency currency) {}
|
||||||
|
|
||||||
|
private BasicTransactionInfo getBasicTransactionInfo() {
|
||||||
|
if (!basicTransactionInfoValid.get()) return null;
|
||||||
|
return new BasicTransactionInfo(
|
||||||
|
DateUtil.localToUTC(parseTimestamp()),
|
||||||
|
new BigDecimal(amountField.getText()),
|
||||||
|
currencyChoiceBox.getValue()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the duplicate transaction validation, which operates on the
|
||||||
|
* basic transaction properties: timestamp, amount, and currency. We listen
|
||||||
|
* for changes to these, and if they're all at least valid, we search for
|
||||||
|
* existing transactions with the same values.
|
||||||
|
*/
|
||||||
|
private void initializeDuplicateTransactionUi() {
|
||||||
|
Property<BasicTransactionInfo> txInfoProperty = new SimpleObjectProperty<>(getBasicTransactionInfo());
|
||||||
|
basicTransactionInfoValid.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (newValue) {
|
||||||
|
txInfoProperty.setValue(new BasicTransactionInfo(
|
||||||
|
DateUtil.localToUTC(parseTimestamp()),
|
||||||
|
new BigDecimal(amountField.getText()),
|
||||||
|
currencyChoiceBox.getValue()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
txInfoProperty.setValue(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AsyncValidationFunction<BasicTransactionInfo> validationFunction = info -> {
|
||||||
|
if (info == null || transaction != null) return CompletableFuture.completedFuture(ValidationResult.valid());
|
||||||
|
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.findDuplicates(info.timestamp(), info.amount(), info.currency())
|
||||||
|
)
|
||||||
|
.thenApply(matches -> matches.stream().map(m -> "Found possible duplicate transaction: #" + m.id).toList())
|
||||||
|
.thenApply(ValidationResult::new);
|
||||||
|
};
|
||||||
|
new ValidationApplier<>(validationFunction)
|
||||||
|
.attach(descriptionField.getParent(), txInfoProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeTagSelectionUi() {
|
||||||
|
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
|
||||||
|
addTagButton.setOnAction(event -> {
|
||||||
|
if (tagsComboBox.getValue() == null) return;
|
||||||
|
String tag = tagsComboBox.getValue().strip();
|
||||||
|
if (!selectedTags.contains(tag)) {
|
||||||
|
selectedTags.add(tag);
|
||||||
|
selectedTags.sort(String::compareToIgnoreCase);
|
||||||
|
}
|
||||||
|
tagsComboBox.setValue(null);
|
||||||
|
});
|
||||||
|
tagsComboBox.setOnKeyPressed(event -> {
|
||||||
|
if (event.getCode() == KeyCode.ENTER) {
|
||||||
|
addTagButton.fire();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node createTagListTile(String tag) {
|
||||||
|
Label label = new Label(tag);
|
||||||
|
label.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
label.getStyleClass().addAll("bold-text");
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setOnAction(event -> selectedTags.remove(tag));
|
||||||
|
BorderPane tile = new BorderPane(label);
|
||||||
|
tile.setRight(removeButton);
|
||||||
|
tile.getStyleClass().addAll("std-spacing");
|
||||||
|
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeLineItemsUi() {
|
||||||
|
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
||||||
|
addLineItemCancelButton.setOnAction(event -> addingLineItemProperty.set(false));
|
||||||
|
addingLineItemProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (!newValue) { // The form has been closed.
|
||||||
|
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
||||||
|
lineItemValueField.setText(null);
|
||||||
|
lineItemDescriptionField.setText(null);
|
||||||
|
lineItemCategoryComboBox.setValue(categoryComboBox.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
||||||
|
BindingUtil.mapContent(lineItemsVBox.getChildren(), lineItems, this::createLineItemTile);
|
||||||
|
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
||||||
|
var lineItemValueValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false))
|
||||||
|
.validatedInitially().attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
||||||
|
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
||||||
|
.addPredicate(s -> s.strip().length() <= TransactionLineItem.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
|
.addPredicate(
|
||||||
|
s -> lineItems.stream().map(TransactionLineItem::getDescription).noneMatch(d -> d.equalsIgnoreCase(s)),
|
||||||
|
"Description must be unique."
|
||||||
|
)
|
||||||
|
).validatedInitially().attachToTextField(lineItemDescriptionField);
|
||||||
|
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
||||||
|
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
||||||
|
addLineItemAddButton.setOnAction(event -> {
|
||||||
|
int quantity = lineItemQuantitySpinner.getValue();
|
||||||
|
BigDecimal valuePerItem = new BigDecimal(lineItemValueField.getText());
|
||||||
|
String description = lineItemDescriptionField.getText().strip();
|
||||||
|
TransactionCategory category = lineItemCategoryComboBox.getValue();
|
||||||
|
Long categoryId = category == null ? null : category.id;
|
||||||
|
long tmpId = tmpLineItemId--;
|
||||||
|
TransactionLineItem tmpItem = new TransactionLineItem(tmpId, -1L, valuePerItem, quantity, -1, description, categoryId);
|
||||||
|
lineItems.add(tmpItem);
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logic for showing an indicator when the line items total exactly matches the entered amount.
|
||||||
|
ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItems);
|
||||||
|
ObservableValue<BigDecimal> lineItemsTotalValue = lineItemsProperty.map(items -> items.stream()
|
||||||
|
.map(TransactionLineItem::getTotalValue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||||
|
ObjectProperty<BigDecimal> amountFieldValue = new SimpleObjectProperty<>(BigDecimal.ZERO);
|
||||||
|
amountField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (newValue == null) {
|
||||||
|
amountFieldValue.set(BigDecimal.ZERO);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
BigDecimal amount = new BigDecimal(newValue);
|
||||||
|
amountFieldValue.set(amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
amountFieldValue.set(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
|
||||||
|
lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
|
||||||
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
|
||||||
|
amountFieldValue.addListener((observable, oldValue, newValue) ->
|
||||||
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
|
||||||
|
|
||||||
|
// Logic for button that syncs line items total to the amount field.
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsAmountSyncButton, lineItemsTotalMatchesAmount.not().and(lineItemsProperty.emptyProperty().not()));
|
||||||
|
lineItemsAmountSyncButton.setOnAction(event -> amountField.setText(
|
||||||
|
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(
|
||||||
|
lineItemsTotalValue.getValue(),
|
||||||
|
currencyChoiceBox.getValue()
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node createLineItemTile(TransactionLineItem item) {
|
||||||
|
TransactionLineItemTile tile = TransactionLineItemTile.build(item, currencyChoiceBox.valueProperty(), categoryComboBox.getItems()).join();
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
removeButton.setOnAction(event -> lineItems.remove(item));
|
||||||
|
Button moveUpButton = new Button("Move Up");
|
||||||
|
moveUpButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveUpButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getFirst().equals(item)));
|
||||||
|
moveUpButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx - 1, item);
|
||||||
|
});
|
||||||
|
Button moveDownButton = new Button("Move Down");
|
||||||
|
moveDownButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveDownButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getLast().equals(item)));
|
||||||
|
moveDownButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx + 1, item);
|
||||||
|
});
|
||||||
|
VBox buttonsBox = new VBox(removeButton, moveUpButton, moveDownButton);
|
||||||
|
buttonsBox.getStyleClass().addAll("std-spacing");
|
||||||
|
tile.setRight(buttonsBox);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||||
return new CreditAndDebitAccounts(
|
return new CreditAndDebitAccounts(
|
||||||
creditAccountSelector.getValue(),
|
creditAccountSelector.getValue(),
|
||||||
|
@ -215,6 +500,29 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
|
||||||
|
return new PredicateValidator<CreditAndDebitAccounts>()
|
||||||
|
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
|
||||||
|
.addPredicate(
|
||||||
|
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
|
||||||
|
"The credit and debit accounts cannot be the same."
|
||||||
|
)
|
||||||
|
.addPredicate(
|
||||||
|
accounts -> (
|
||||||
|
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
|
||||||
|
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
|
||||||
|
),
|
||||||
|
"Linked accounts must use the same currency."
|
||||||
|
)
|
||||||
|
.addPredicate(
|
||||||
|
accounts -> (
|
||||||
|
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
|
||||||
|
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
|
||||||
|
),
|
||||||
|
"Linked accounts must not be archived."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private LocalDateTime parseTimestamp() {
|
private LocalDateTime parseTimestamp() {
|
||||||
List<DateTimeFormatter> formatters = List.of(
|
List<DateTimeFormatter> formatters = List.of(
|
||||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.AnalyticsRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.TimestampRange;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class EditVendorController implements RouteSelectionListener {
|
||||||
|
private TransactionVendor vendor;
|
||||||
|
|
||||||
|
@FXML public TextField nameField;
|
||||||
|
@FXML public TextArea descriptionField;
|
||||||
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
|
@FXML public Label totalSpentField;
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||||
|
.addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
|
||||||
|
// A predicate that prevents duplicate names.
|
||||||
|
.addAsyncPredicate(
|
||||||
|
s -> {
|
||||||
|
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
|
||||||
|
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionVendorRepository.class,
|
||||||
|
repo -> {
|
||||||
|
var vendorByName = repo.findByName(s).orElse(null);
|
||||||
|
if (this.vendor != null) {
|
||||||
|
return this.vendor.equals(vendorByName) || vendorByName == null;
|
||||||
|
}
|
||||||
|
return vendorByName == null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"Vendor with this name already exists."
|
||||||
|
)
|
||||||
|
).validatedInitially().attachToTextField(nameField);
|
||||||
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addPredicate(
|
||||||
|
s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
|
||||||
|
"Description is too long."
|
||||||
|
)
|
||||||
|
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||||
|
|
||||||
|
var formValid = nameValid.and(descriptionValid);
|
||||||
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
if (context instanceof TransactionVendor tv) {
|
||||||
|
this.vendor = tv;
|
||||||
|
nameField.setText(vendor.getName());
|
||||||
|
descriptionField.setText(vendor.getDescription());
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
AnalyticsRepository.class,
|
||||||
|
repo -> repo.getVendorSpend(TimestampRange.unbounded(), vendor.id)
|
||||||
|
).thenAccept(amounts -> {
|
||||||
|
String text = amounts.stream()
|
||||||
|
.map(CurrencyUtil::formatMoney)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
Platform.runLater(() -> totalSpentField.setText(text.isBlank() ? "None" : text));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nameField.setText(null);
|
||||||
|
descriptionField.setText(null);
|
||||||
|
totalSpentField.setText(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void save() {
|
||||||
|
String name = nameField.getText().strip();
|
||||||
|
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
||||||
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
|
if (vendor != null) {
|
||||||
|
ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
|
||||||
|
} else {
|
||||||
|
ds.useRepo(TransactionVendorRepository.class, repo -> {
|
||||||
|
if (description == null || description.isEmpty()) {
|
||||||
|
repo.insert(name);
|
||||||
|
} else {
|
||||||
|
repo.insert(name, description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.replace("vendors");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void cancel() {
|
||||||
|
router.navigateBackAndClear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,7 +40,7 @@ public class MainViewController {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.navigate("accounts");
|
router.navigate("dashboard");
|
||||||
|
|
||||||
// Initialize the help manual components.
|
// Initialize the help manual components.
|
||||||
helpPane.managedProperty().bind(helpPane.visibleProperty());
|
helpPane.managedProperty().bind(helpPane.visibleProperty());
|
||||||
|
@ -75,14 +75,6 @@ public class MainViewController {
|
||||||
router.navigateForward();
|
router.navigateForward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void goToAccounts() {
|
|
||||||
router.replace("accounts");
|
|
||||||
}
|
|
||||||
|
|
||||||
@FXML public void goToTransactions() {
|
|
||||||
router.replace("transactions");
|
|
||||||
}
|
|
||||||
|
|
||||||
@FXML public void viewProfiles() {
|
@FXML public void viewProfiles() {
|
||||||
ProfilesStage.open(mainContainer.getScene().getWindow());
|
ProfilesStage.open(mainContainer.getScene().getWindow());
|
||||||
}
|
}
|
||||||
|
@ -106,4 +98,12 @@ public class MainViewController {
|
||||||
@FXML public void helpViewTransactions() {
|
@FXML public void helpViewTransactions() {
|
||||||
helpRouter.replace("transactions");
|
helpRouter.replace("transactions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FXML public void goToDashboard() {
|
||||||
|
router.replace("dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void goToSqlConsole() {
|
||||||
|
router.replace("sql-console");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +1,70 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.control.Alert;
|
import javafx.scene.control.Alert;
|
||||||
import javafx.scene.control.ButtonType;
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.stage.Modality;
|
import javafx.stage.Modality;
|
||||||
|
import javafx.stage.Window;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class for standardized popups and confirmation dialogs for the app.
|
* Helper class for standardized popups and confirmation dialogs for the app.
|
||||||
*/
|
*/
|
||||||
public class Popups {
|
public class Popups {
|
||||||
public static boolean confirm(String text) {
|
public static boolean confirm(Window owner, String text) {
|
||||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
|
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
|
||||||
|
alert.initOwner(owner);
|
||||||
alert.initModality(Modality.APPLICATION_MODAL);
|
alert.initModality(Modality.APPLICATION_MODAL);
|
||||||
var result = alert.showAndWait();
|
var result = alert.showAndWait();
|
||||||
return result.isPresent() && result.get() == ButtonType.OK;
|
return result.isPresent() && result.get() == ButtonType.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void message(String text) {
|
public static boolean confirm(Node node, String text) {
|
||||||
|
return confirm(getWindowFromNode(node), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void message(Window owner, String text) {
|
||||||
Alert alert = new Alert(Alert.AlertType.NONE, text);
|
Alert alert = new Alert(Alert.AlertType.NONE, text);
|
||||||
|
alert.initOwner(owner);
|
||||||
alert.initModality(Modality.APPLICATION_MODAL);
|
alert.initModality(Modality.APPLICATION_MODAL);
|
||||||
alert.getButtonTypes().setAll(ButtonType.OK);
|
alert.getButtonTypes().setAll(ButtonType.OK);
|
||||||
alert.showAndWait();
|
alert.showAndWait();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void error(String text) {
|
public static void message(Node node, String text) {
|
||||||
|
message(getWindowFromNode(node), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(Window owner, String text) {
|
||||||
Alert alert = new Alert(Alert.AlertType.WARNING, text);
|
Alert alert = new Alert(Alert.AlertType.WARNING, text);
|
||||||
|
alert.initOwner(owner);
|
||||||
alert.initModality(Modality.APPLICATION_MODAL);
|
alert.initModality(Modality.APPLICATION_MODAL);
|
||||||
alert.showAndWait();
|
alert.showAndWait();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void error(Node node, String text) {
|
||||||
|
error(getWindowFromNode(node), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(Window owner, Exception e) {
|
||||||
|
error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(Node node, Exception e) {
|
||||||
|
error(getWindowFromNode(node), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void errorLater(Node node, Exception e) {
|
||||||
|
Platform.runLater(() -> error(node, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Window getWindowFromNode(Node n) {
|
||||||
|
Window owner = null;
|
||||||
|
Scene scene = n.getScene();
|
||||||
|
if (scene != null) {
|
||||||
|
owner = scene.getWindow();
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,11 @@ package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.PerfinApp;
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
|
import com.andrewlalis.perfin.data.SampleProfileGenerator;
|
||||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.ProfileBackups;
|
||||||
|
import com.andrewlalis.perfin.model.ProfileLoader;
|
||||||
import com.andrewlalis.perfin.view.ProfilesStage;
|
import com.andrewlalis.perfin.view.ProfilesStage;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
@ -20,6 +23,7 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@ -44,20 +48,30 @@ public class ProfilesViewController {
|
||||||
@FXML public void addProfile() {
|
@FXML public void addProfile() {
|
||||||
String name = newProfileNameField.getText();
|
String name = newProfileNameField.getText();
|
||||||
boolean valid = Profile.validateName(name);
|
boolean valid = Profile.validateName(name);
|
||||||
if (valid && !Profile.getAvailableProfiles().contains(name)) {
|
if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
|
||||||
boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
|
boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?");
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
if (openProfile(name, false)) {
|
if (openProfile(name, false)) {
|
||||||
Popups.message("Created new profile \"" + name + "\" and loaded it.");
|
Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it.");
|
||||||
}
|
}
|
||||||
newProfileNameField.clear();
|
newProfileNameField.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FXML public void createSampleProfile() {
|
||||||
|
SampleProfileGenerator generator = new SampleProfileGenerator(PerfinApp.profileLoader);
|
||||||
|
try {
|
||||||
|
generator.createSampleProfile();
|
||||||
|
refreshAvailableProfiles();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Popups.error(profilesVBox, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void refreshAvailableProfiles() {
|
private void refreshAvailableProfiles() {
|
||||||
List<String> profileNames = Profile.getAvailableProfiles();
|
List<String> profileNames = ProfileLoader.getAvailableProfiles();
|
||||||
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
|
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
|
||||||
List<Node> nodes = new ArrayList<>(profileNames.size());
|
List<Node> nodes = new ArrayList<>(profileNames.size());
|
||||||
for (String profileName : profileNames) {
|
for (String profileName : profileNames) {
|
||||||
boolean isCurrent = profileName.equals(currentProfile);
|
boolean isCurrent = profileName.equals(currentProfile);
|
||||||
|
@ -91,6 +105,9 @@ public class ProfilesViewController {
|
||||||
PerfinApp.instance.getHostServices().showDocument(Profile.getDir(profileName).toUri().toString());
|
PerfinApp.instance.getHostServices().showDocument(Profile.getDir(profileName).toUri().toString());
|
||||||
});
|
});
|
||||||
buttonBox.getChildren().add(viewFilesButton);
|
buttonBox.getChildren().add(viewFilesButton);
|
||||||
|
Button backupButton = new Button("Backup");
|
||||||
|
backupButton.setOnAction(event -> makeBackup(profileName));
|
||||||
|
buttonBox.getChildren().add(backupButton);
|
||||||
Button deleteButton = new Button("Delete");
|
Button deleteButton = new Button("Delete");
|
||||||
deleteButton.setOnAction(event -> deleteProfile(profileName));
|
deleteButton.setOnAction(event -> deleteProfile(profileName));
|
||||||
buttonBox.getChildren().add(deleteButton);
|
buttonBox.getChildren().add(deleteButton);
|
||||||
|
@ -104,30 +121,40 @@ public class ProfilesViewController {
|
||||||
private boolean openProfile(String name, boolean showPopup) {
|
private boolean openProfile(String name, boolean showPopup) {
|
||||||
log.info("Opening profile \"{}\".", name);
|
log.info("Opening profile \"{}\".", name);
|
||||||
try {
|
try {
|
||||||
Profile.load(name);
|
Profile.setCurrent(PerfinApp.profileLoader.load(name));
|
||||||
|
ProfileLoader.saveLastProfile(name);
|
||||||
ProfilesStage.closeView();
|
ProfilesStage.closeView();
|
||||||
router.replace("accounts");
|
router.replace("dashboard");
|
||||||
if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded.");
|
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
|
||||||
return true;
|
return true;
|
||||||
} catch (ProfileLoadException e) {
|
} catch (ProfileLoadException e) {
|
||||||
Popups.error("Failed to load the profile: " + e.getMessage());
|
Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void makeBackup(String name) {
|
||||||
|
try {
|
||||||
|
Path backupFile = ProfileBackups.makeBackup(name);
|
||||||
|
Popups.message(profilesVBox, "A new backup was created at " + backupFile.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
Popups.error(profilesVBox, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void deleteProfile(String name) {
|
private void deleteProfile(String name) {
|
||||||
boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
|
boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
|
||||||
if (confirmA) {
|
if (confirmA) {
|
||||||
boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
|
boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
|
||||||
if (confirmB) {
|
if (confirmB) {
|
||||||
try {
|
try {
|
||||||
FileUtil.deleteDirRecursive(Profile.getDir(name));
|
FileUtil.deleteDirRecursive(Profile.getDir(name));
|
||||||
// Reset the app's "last profile" to the default if it was the deleted profile.
|
// Reset the app's "last profile" to the default if it was the deleted profile.
|
||||||
if (Profile.getLastProfile().equals(name)) {
|
if (ProfileLoader.getLastProfile().equals(name)) {
|
||||||
Profile.saveLastProfile("default");
|
ProfileLoader.saveLastProfile("default");
|
||||||
}
|
}
|
||||||
// If the current profile was deleted, switch to the default.
|
// If the current profile was deleted, switch to the default.
|
||||||
if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) {
|
if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
|
||||||
openProfile("default", true);
|
openProfile("default", true);
|
||||||
}
|
}
|
||||||
refreshAvailableProfiles();
|
refreshAvailableProfiles();
|
||||||
|
|
|
@ -0,0 +1,285 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.SavedQueryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||||
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.layout.AnchorPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.stage.FileChooser;
|
||||||
|
import javafx.stage.Modality;
|
||||||
|
import javafx.stage.StageStyle;
|
||||||
|
import javafx.stage.Window;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the SQL Console View, in which the user can write and execute
|
||||||
|
* arbitrary SQL queries on the database. This allows power users to create
|
||||||
|
* custom analytics queries and get exactly the data they want, without fiddling
|
||||||
|
* with user-friendly search fields.
|
||||||
|
*/
|
||||||
|
public class SqlConsoleViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
|
@FXML public TextArea sqlEditorTextArea;
|
||||||
|
@FXML public TextArea outputTextArea;
|
||||||
|
@FXML public VBox savedQueriesVBox;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
sqlEditorTextArea.clear();
|
||||||
|
outputTextArea.clear();
|
||||||
|
refreshSavedQueries();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void executeQuery() {
|
||||||
|
List<String> queries = getCurrentQueries();
|
||||||
|
outputTextArea.clear();
|
||||||
|
JdbcDataSource dataSource = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||||
|
try (
|
||||||
|
var conn = dataSource.getConnection();
|
||||||
|
var stmt = conn.createStatement()
|
||||||
|
) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int queryIdx = 0; queryIdx < queries.size(); queryIdx++) {
|
||||||
|
sb.append("Query ").append(queryIdx + 1).append(" of ").append(queries.size()).append(":\n");
|
||||||
|
String query = queries.get(queryIdx);
|
||||||
|
ResultSet rs = stmt.executeQuery(query);
|
||||||
|
int columnCount = rs.getMetaData().getColumnCount();
|
||||||
|
|
||||||
|
for (int i = 1; i <= columnCount; i++) {
|
||||||
|
sb.append(rs.getMetaData().getColumnLabel(i));
|
||||||
|
if (i < columnCount) sb.append(", ");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
while (rs.next()) {
|
||||||
|
for (int i = 1; i <= columnCount; i++) {
|
||||||
|
sb.append(rs.getString(i));
|
||||||
|
if (i < columnCount) sb.append(", ");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
if (queryIdx < queries.size() - 1) {
|
||||||
|
sb.append("-----\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputTextArea.setText(sb.toString());
|
||||||
|
} catch (SQLException e) {
|
||||||
|
outputTextArea.setText("Error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void saveQuery() {
|
||||||
|
if (sqlEditorTextArea.getText().isBlank()) {
|
||||||
|
Popups.message(sqlEditorTextArea, "Cannot save an empty query.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TextInputDialog dialog = new TextInputDialog();
|
||||||
|
dialog.setTitle("Save Query");
|
||||||
|
dialog.setContentText("Enter a name for this query.");
|
||||||
|
Optional<String> result = dialog.showAndWait();
|
||||||
|
if (result.isPresent()) {
|
||||||
|
SavedQueryRepository repo = Profile.getCurrent().dataSource().getSavedQueryRepository();
|
||||||
|
String name = result.get().strip();
|
||||||
|
if (
|
||||||
|
repo.getSavedQueries().contains(name) &&
|
||||||
|
!Popups.confirm(sqlEditorTextArea, "Are you sure you want to overwrite this saved query?")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String content = sqlEditorTextArea.getText().strip();
|
||||||
|
repo.createSavedQuery(name, content);
|
||||||
|
refreshSavedQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void exportToFile() {
|
||||||
|
if (sqlEditorTextArea.getText().isBlank()) {
|
||||||
|
Popups.message(sqlEditorTextArea, "Cannot export the results of an empty query.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getCurrentQueries().size() > 1) {
|
||||||
|
Popups.message(sqlEditorTextArea, "Note: Export to file will only export the results of your first query.");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileChooser fileChooser = new FileChooser();
|
||||||
|
fileChooser.setTitle("Export to File");
|
||||||
|
fileChooser.setInitialFileName("export.csv");
|
||||||
|
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
|
||||||
|
"CSV Files", ".csv"
|
||||||
|
));
|
||||||
|
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
|
||||||
|
"JSON Files", ".json"
|
||||||
|
));
|
||||||
|
fileChooser.setInitialDirectory(Profile.getCurrent().dataSource().getContentDir().toFile());
|
||||||
|
File chosenFile = fileChooser.showSaveDialog(sqlEditorTextArea.getScene().getWindow());
|
||||||
|
if (chosenFile == null) return;
|
||||||
|
|
||||||
|
String name = chosenFile.getName().strip().toLowerCase();
|
||||||
|
if (!name.endsWith(".csv") && !name.endsWith(".json")) {
|
||||||
|
Popups.error(sqlEditorTextArea, "Invalid file format. Only CSV and JSON are permitted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String query = getCurrentQueries().getFirst();
|
||||||
|
JdbcDataSource dataSource = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||||
|
try (
|
||||||
|
var conn = dataSource.getConnection();
|
||||||
|
var stmt = conn.createStatement()
|
||||||
|
) {
|
||||||
|
ResultSet rs = stmt.executeQuery(query);
|
||||||
|
if (name.endsWith(".csv")) {
|
||||||
|
writeQueryResultsToCsv(rs, chosenFile.toPath());
|
||||||
|
} else if (name.endsWith(".json")) {
|
||||||
|
writeQueryResultsToJson(rs, chosenFile.toPath());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Popups.error(sqlEditorTextArea, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeQueryResultsToCsv(ResultSet rs, Path file) throws SQLException, IOException {
|
||||||
|
try (var out = Files.newOutputStream(file); var writer = new PrintWriter(out)) {
|
||||||
|
final int columnCount = rs.getMetaData().getColumnCount();
|
||||||
|
// First write the header.
|
||||||
|
for (int i = 1; i <= columnCount; i++) {
|
||||||
|
writer.append(FileUtil.escapeCSVText(rs.getMetaData().getColumnLabel(i)));
|
||||||
|
if (i < columnCount) writer.append(',');
|
||||||
|
}
|
||||||
|
writer.println();
|
||||||
|
// Then write the body rows.
|
||||||
|
while (rs.next()) {
|
||||||
|
for (int i = 1; i <= columnCount; i++) {
|
||||||
|
writer.append(FileUtil.escapeCSVText(rs.getString(i)));
|
||||||
|
if (i < columnCount) writer.append(',');
|
||||||
|
}
|
||||||
|
writer.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeQueryResultsToJson(ResultSet rs, Path file) throws SQLException, IOException {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
JsonNodeFactory nf = mapper.getNodeFactory();
|
||||||
|
final int columnCount = rs.getMetaData().getColumnCount();
|
||||||
|
try (
|
||||||
|
var out = Files.newOutputStream(file);
|
||||||
|
var arrayWriter = mapper.writerWithDefaultPrettyPrinter().writeValuesAsArray(out)
|
||||||
|
) {
|
||||||
|
while (rs.next()) {
|
||||||
|
ObjectNode obj = mapper.createObjectNode();
|
||||||
|
for (int i = 1; i <= columnCount; i++) {
|
||||||
|
String label = rs.getMetaData().getColumnLabel(i);
|
||||||
|
int type = rs.getMetaData().getColumnType(i);
|
||||||
|
JsonNode valueNode = switch (type) {
|
||||||
|
case Types.INTEGER | Types.BIGINT -> nf.numberNode(rs.getLong(i));
|
||||||
|
case Types.FLOAT | Types.DECIMAL -> nf.numberNode(rs.getDouble(i));
|
||||||
|
case Types.NUMERIC -> nf.numberNode(rs.getBigDecimal(i));
|
||||||
|
case Types.BOOLEAN -> nf.booleanNode(rs.getBoolean(i));
|
||||||
|
default -> nf.textNode(rs.getString(i));
|
||||||
|
};
|
||||||
|
obj.set(label, valueNode);
|
||||||
|
}
|
||||||
|
arrayWriter.write(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSavedQueries() {
|
||||||
|
savedQueriesVBox.getChildren().clear();
|
||||||
|
List<String> savedQueries = Profile.getCurrent().dataSource()
|
||||||
|
.getSavedQueryRepository().getSavedQueries();
|
||||||
|
savedQueriesVBox.getChildren().addAll(savedQueries.stream().map(this::makeQueryTile).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node makeQueryTile(String name) {
|
||||||
|
AnchorPane pane = new AnchorPane();
|
||||||
|
pane.getStyleClass().addAll("tile");
|
||||||
|
Label nameLabel = new Label(name);
|
||||||
|
AnchorPane.setLeftAnchor(nameLabel, 0.0);
|
||||||
|
AnchorPane.setTopAnchor(nameLabel, 0.0);
|
||||||
|
AnchorPane.setBottomAnchor(nameLabel, 0.0);
|
||||||
|
pane.getChildren().add(nameLabel);
|
||||||
|
|
||||||
|
HBox buttonsBox = new HBox();
|
||||||
|
buttonsBox.getStyleClass().addAll("std-spacing", "small-font");
|
||||||
|
Button loadButton = new Button("Load");
|
||||||
|
loadButton.setOnAction(event -> sqlEditorTextArea.setText(
|
||||||
|
Profile.getCurrent().dataSource().getSavedQueryRepository()
|
||||||
|
.getSavedQueryContent(name)
|
||||||
|
));
|
||||||
|
buttonsBox.getChildren().add(loadButton);
|
||||||
|
Button deleteButton = new Button("Delete");
|
||||||
|
deleteButton.setOnAction(event -> {
|
||||||
|
if (Popups.confirm(pane, "Are you sure you want to delete this query?")) {
|
||||||
|
Profile.getCurrent().dataSource().getSavedQueryRepository().deleteSavedQuery(name);
|
||||||
|
refreshSavedQueries();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
buttonsBox.getChildren().add(deleteButton);
|
||||||
|
|
||||||
|
AnchorPane.setRightAnchor(buttonsBox, 0.0);
|
||||||
|
AnchorPane.setTopAnchor(buttonsBox, 0.0);
|
||||||
|
AnchorPane.setBottomAnchor(buttonsBox, 0.0);
|
||||||
|
pane.getChildren().add(buttonsBox);
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getCurrentQueries() {
|
||||||
|
String queryText = sqlEditorTextArea.getText().strip();
|
||||||
|
String[] rawQueries = queryText.split("\\s*;\\s*");
|
||||||
|
return Arrays.stream(rawQueries)
|
||||||
|
.filter(s -> !s.isBlank())
|
||||||
|
.filter(s -> !s.startsWith("#") && !s.startsWith("//"))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void showSchema() {
|
||||||
|
SchemaDialog dialog = new SchemaDialog(sqlEditorTextArea.getScene().getWindow());
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class SchemaDialog extends Dialog<Void> {
|
||||||
|
public SchemaDialog(Window owner) {
|
||||||
|
DialogPane pane = new DialogPane();
|
||||||
|
TextArea schemaTextArea = new TextArea();
|
||||||
|
schemaTextArea.setEditable(false);
|
||||||
|
schemaTextArea.getStyleClass().addAll("mono-font", "small-font");
|
||||||
|
try (var in = SqlConsoleViewController.class.getResourceAsStream("/sql/schema.sql")) {
|
||||||
|
if (in == null) throw new IOException("Could not load database schema from resource location.");
|
||||||
|
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
schemaTextArea.setText(schemaStr);
|
||||||
|
} catch (IOException e) {
|
||||||
|
schemaTextArea.setText("Failed to load schema file!");
|
||||||
|
}
|
||||||
|
pane.setContent(schemaTextArea);
|
||||||
|
pane.getButtonTypes().add(ButtonType.OK);
|
||||||
|
|
||||||
|
initOwner(owner);
|
||||||
|
initModality(Modality.NONE);
|
||||||
|
initStyle(StageStyle.DECORATED);
|
||||||
|
setResizable(true);
|
||||||
|
setTitle("Perfin Database Schema");
|
||||||
|
setDialogPane(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
public class TagsViewController implements RouteSelectionListener {
|
||||||
|
@FXML public VBox tagsVBox;
|
||||||
|
private final ObservableList<String> tags = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
refreshTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshTags() {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionRepository.class,
|
||||||
|
TransactionRepository::findAllTags
|
||||||
|
).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node buildTagTile(String name) {
|
||||||
|
BorderPane tile = new BorderPane();
|
||||||
|
tile.getStyleClass().addAll("tile");
|
||||||
|
Label nameLabel = new Label(name);
|
||||||
|
nameLabel.getStyleClass().addAll("bold-text");
|
||||||
|
Label usagesLabel = new Label();
|
||||||
|
usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.countTagUsages(name)
|
||||||
|
).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
|
||||||
|
VBox contentBox = new VBox(nameLabel, usagesLabel);
|
||||||
|
tile.setLeft(contentBox);
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setOnAction(event -> {
|
||||||
|
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
|
||||||
|
if (confirm) {
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.deleteTag(name)
|
||||||
|
);
|
||||||
|
refreshTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tile.setRight(removeButton);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,23 +3,47 @@ package com.andrewlalis.perfin.control;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
|
||||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||||
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
|
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.ListProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleListProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Hyperlink;
|
import javafx.scene.control.Hyperlink;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
import javafx.scene.text.TextFlow;
|
import javafx.scene.text.TextFlow;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class TransactionViewController {
|
public class TransactionViewController {
|
||||||
private Transaction transaction;
|
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
||||||
|
|
||||||
|
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableValue<Currency> observableCurrency = transactionProperty.map(Transaction::getCurrency);
|
||||||
|
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
||||||
|
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
|
||||||
|
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
|
||||||
|
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
|
||||||
|
@ -27,51 +51,134 @@ public class TransactionViewController {
|
||||||
@FXML public Label timestampLabel;
|
@FXML public Label timestampLabel;
|
||||||
@FXML public Label descriptionLabel;
|
@FXML public Label descriptionLabel;
|
||||||
|
|
||||||
|
@FXML public Label vendorLabel;
|
||||||
|
@FXML public Circle categoryColorIndicator;
|
||||||
|
@FXML public Label categoryLabel;
|
||||||
|
@FXML public Label tagsLabel;
|
||||||
|
|
||||||
@FXML public Hyperlink debitAccountLink;
|
@FXML public Hyperlink debitAccountLink;
|
||||||
@FXML public Hyperlink creditAccountLink;
|
@FXML public Hyperlink creditAccountLink;
|
||||||
|
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
|
|
||||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
configureAccountLinkBindings(debitAccountLink);
|
titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
|
||||||
configureAccountLinkBindings(creditAccountLink);
|
amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
|
||||||
|
timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
|
||||||
|
descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
|
||||||
|
|
||||||
|
PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
|
||||||
|
vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
|
||||||
|
|
||||||
|
PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
|
||||||
|
categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
|
||||||
|
categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
|
||||||
|
|
||||||
|
PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
|
||||||
|
tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
|
||||||
|
|
||||||
|
TextFlow debitText = (TextFlow) debitAccountLink.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
|
||||||
|
debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
|
||||||
|
debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
|
||||||
|
if (la.hasDebit()) {
|
||||||
|
return event -> router.navigate("account", la.debitAccount());
|
||||||
|
}
|
||||||
|
return event -> {};
|
||||||
|
}));
|
||||||
|
TextFlow creditText = (TextFlow) creditAccountLink.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
|
||||||
|
creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
|
||||||
|
creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
|
||||||
|
if (la.hasCredit()) {
|
||||||
|
return event -> router.navigate("account", la.creditAccount());
|
||||||
|
}
|
||||||
|
return event -> {};
|
||||||
|
}));
|
||||||
|
|
||||||
|
VBox lineItemsContainer = (VBox) lineItemsVBox.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsContainer, lineItemsProperty.emptyProperty().not());
|
||||||
|
lineItemsProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
lineItemsVBox.getChildren().clear();
|
||||||
|
Label loadingLabel = new Label("Loading line items...");
|
||||||
|
loadingLabel.getStyleClass().addAll("secondary-color-text-fill");
|
||||||
|
lineItemsVBox.getChildren().add(loadingLabel);
|
||||||
|
List<CompletableFuture<TransactionLineItemTile>> tileFutures = lineItemsList.stream()
|
||||||
|
.map(item -> TransactionLineItemTile.build(item, observableCurrency, null))
|
||||||
|
.toList();
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
List<TransactionLineItemTile> tiles = tileFutures.stream()
|
||||||
|
.map(CompletableFuture::join).toList();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
lineItemsVBox.getChildren().remove(loadingLabel);
|
||||||
|
lineItemsVBox.getChildren().addAll(tiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
attachmentsViewPane.hideIfEmpty();
|
attachmentsViewPane.hideIfEmpty();
|
||||||
|
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
||||||
|
|
||||||
|
transactionProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (newValue == null) {
|
||||||
|
linkedAccountsProperty.set(null);
|
||||||
|
vendorProperty.set(null);
|
||||||
|
categoryProperty.set(null);
|
||||||
|
tagsList.clear();
|
||||||
|
lineItemsList.clear();
|
||||||
|
attachmentsList.clear();
|
||||||
|
} else {
|
||||||
|
updateLinkedData(newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setTransaction(Transaction transaction) {
|
public void setTransaction(Transaction transaction) {
|
||||||
this.transaction = transaction;
|
this.transactionProperty.set(transaction);
|
||||||
if (transaction == null) return;
|
}
|
||||||
titleLabel.setText("Transaction #" + transaction.id);
|
|
||||||
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
|
private void updateLinkedData(Transaction tx) {
|
||||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
|
var ds = Profile.getCurrent().dataSource();
|
||||||
descriptionLabel.setText(transaction.getDescription());
|
Thread.ofVirtual().start(() -> {
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
try (
|
||||||
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
List<Attachment> attachments = repo.findAttachments(transaction.id);
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
Platform.runLater(() -> {
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
if (accounts.hasDebit()) {
|
var lineItemsRepo = ds.getTransactionLineItemRepository()
|
||||||
debitAccountLink.setText(accounts.debitAccount().getShortName());
|
) {
|
||||||
debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
|
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
||||||
} else {
|
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
||||||
debitAccountLink.setText(null);
|
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
||||||
}
|
final var attachments = transactionRepo.findAttachments(tx.id);
|
||||||
if (accounts.hasCredit()) {
|
final var tags = transactionRepo.findTags(tx.id);
|
||||||
creditAccountLink.setText(accounts.creditAccount().getShortName());
|
final var lineItems = lineItemsRepo.findItems(tx.id);
|
||||||
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
|
Platform.runLater(() -> {
|
||||||
} else {
|
linkedAccountsProperty.set(linkedAccounts);
|
||||||
creditAccountLink.setText(null);
|
vendorProperty.set(vendor);
|
||||||
}
|
categoryProperty.set(category);
|
||||||
attachmentsViewPane.setAttachments(attachments);
|
attachmentsList.setAll(attachments);
|
||||||
});
|
tagsList.setAll(tags);
|
||||||
|
lineItemsList.setAll(lineItems);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to fetch additional transaction data.", e);
|
||||||
|
Popups.errorLater(titleLabel, e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void editTransaction() {
|
@FXML public void editTransaction() {
|
||||||
router.navigate("edit-transaction", this.transaction);
|
router.navigate("edit-transaction", this.transactionProperty.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void deleteTransaction() {
|
@FXML public void deleteTransaction() {
|
||||||
boolean confirm = Popups.confirm(
|
boolean confirm = Popups.confirm(
|
||||||
|
titleLabel,
|
||||||
"Are you sure you want to delete this transaction? This will " +
|
"Are you sure you want to delete this transaction? This will " +
|
||||||
"permanently remove the transaction and its effects on any linked " +
|
"permanently remove the transaction and its effects on any linked " +
|
||||||
"accounts, as well as remove any attachments from storage within " +
|
"accounts, as well as remove any attachments from storage within " +
|
||||||
|
@ -81,15 +188,8 @@ public class TransactionViewController {
|
||||||
"it's derived from the most recent balance-record, and transactions."
|
"it's derived from the most recent balance-record, and transactions."
|
||||||
);
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id));
|
Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
|
||||||
router.replace("transactions");
|
router.replace("transactions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureAccountLinkBindings(Hyperlink link) {
|
|
||||||
TextFlow parent = (TextFlow) link.getParent();
|
|
||||||
parent.managedProperty().bind(parent.visibleProperty());
|
|
||||||
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
|
|
||||||
link.setText(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,17 @@ package com.andrewlalis.perfin.control;
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||||
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.data.pagination.Sort;
|
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
|
||||||
|
import com.andrewlalis.perfin.data.search.SearchFilter;
|
||||||
import com.andrewlalis.perfin.data.util.Pair;
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
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 com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.SceneUtil;
|
import com.andrewlalis.perfin.view.SceneUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
|
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
|
||||||
|
@ -21,16 +24,14 @@ import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.beans.value.ObservableValue;
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.stage.FileChooser;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.util.ArrayList;
|
||||||
import java.io.PrintWriter;
|
import java.util.Arrays;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -41,12 +42,19 @@ import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
* to a specific page.
|
* to a specific page.
|
||||||
*/
|
*/
|
||||||
public class TransactionsViewController implements RouteSelectionListener {
|
public class TransactionsViewController implements RouteSelectionListener {
|
||||||
public static List<Sort> DEFAULT_SORTS = List.of(Sort.desc("timestamp"));
|
public static List<Sort> DEFAULT_SORTS = List.of(
|
||||||
|
Sort.desc("timestamp"),
|
||||||
|
Sort.desc("amount"),
|
||||||
|
Sort.desc("currency")
|
||||||
|
);
|
||||||
public record RouteContext(Long selectedTransactionId) {}
|
public record RouteContext(Long selectedTransactionId) {}
|
||||||
|
|
||||||
@FXML public BorderPane transactionsListBorderPane;
|
@FXML public BorderPane transactionsListBorderPane;
|
||||||
|
@FXML public TextField searchField;
|
||||||
@FXML public AccountSelectionBox filterByAccountComboBox;
|
@FXML public AccountSelectionBox filterByAccountComboBox;
|
||||||
|
|
||||||
@FXML public VBox transactionsVBox;
|
@FXML public VBox transactionsVBox;
|
||||||
|
|
||||||
private DataSourcePaginationControls paginationControls;
|
private DataSourcePaginationControls paginationControls;
|
||||||
|
|
||||||
|
|
||||||
|
@ -59,33 +67,33 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
paginationControls.setPage(1);
|
paginationControls.setPage(1);
|
||||||
selectedTransaction.set(null);
|
selectedTransaction.set(null);
|
||||||
});
|
});
|
||||||
|
// Add a listener to the search field that sets the page to 1 (thus
|
||||||
|
// doing a new search with the contents of the field), and deselects any
|
||||||
|
// selected transaction.
|
||||||
|
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
paginationControls.setPage(1);
|
||||||
|
selectedTransaction.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
this.paginationControls = new DataSourcePaginationControls(
|
this.paginationControls = new DataSourcePaginationControls(
|
||||||
transactionsVBox.getChildren(),
|
transactionsVBox.getChildren(),
|
||||||
new DataSourcePaginationControls.PageFetcherFunction() {
|
new DataSourcePaginationControls.PageFetcherFunction() {
|
||||||
@Override
|
@Override
|
||||||
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
|
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
|
||||||
Account accountFilter = filterByAccountComboBox.getValue();
|
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||||
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
|
try (var conn = ds.getConnection()) {
|
||||||
Page<Transaction> result;
|
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
|
||||||
if (accountFilter == null) {
|
return searcher.search(pagination, getCurrentSearchFilters())
|
||||||
result = repo.findAll(pagination);
|
.map(TransactionsViewController.this::makeTile);
|
||||||
} else {
|
|
||||||
result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
|
|
||||||
}
|
|
||||||
return result.map(TransactionsViewController.this::makeTile);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getTotalCount() throws Exception {
|
public int getTotalCount() throws Exception {
|
||||||
Account accountFilter = filterByAccountComboBox.getValue();
|
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||||
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
|
try (var conn = ds.getConnection()) {
|
||||||
if (accountFilter == null) {
|
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
|
||||||
return (int) repo.countAll();
|
return (int) searcher.resultCount(getCurrentSearchFilters());
|
||||||
} else {
|
|
||||||
return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -98,18 +106,13 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
detailPanel.minWidthProperty().bind(halfWidthProp);
|
detailPanel.minWidthProperty().bind(halfWidthProp);
|
||||||
detailPanel.maxWidthProperty().bind(halfWidthProp);
|
detailPanel.maxWidthProperty().bind(halfWidthProp);
|
||||||
detailPanel.prefWidthProperty().bind(halfWidthProp);
|
detailPanel.prefWidthProperty().bind(halfWidthProp);
|
||||||
detailPanel.managedProperty().bind(detailPanel.visibleProperty());
|
BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
|
||||||
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
|
|
||||||
|
|
||||||
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
|
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
|
||||||
TransactionViewController transactionViewController = detailComponents.second();
|
TransactionViewController transactionViewController = detailComponents.second();
|
||||||
BorderPane transactionDetailView = detailComponents.first();
|
BorderPane transactionDetailView = detailComponents.first();
|
||||||
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
|
|
||||||
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
|
|
||||||
detailPanel.getChildren().add(transactionDetailView);
|
detailPanel.getChildren().add(transactionDetailView);
|
||||||
selectedTransaction.addListener((observable, oldValue, newValue) -> {
|
selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
|
||||||
transactionViewController.setTransaction(newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the transactions when a new profile is loaded.
|
// Clear the transactions when a new profile is loaded.
|
||||||
Profile.whenLoaded(profile -> {
|
Profile.whenLoaded(profile -> {
|
||||||
|
@ -121,10 +124,10 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
paginationControls.sorts.setAll(DEFAULT_SORTS);
|
paginationControls.sorts.setAll(DEFAULT_SORTS);
|
||||||
transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially.
|
selectedTransaction.set(null); // Initially set the selected transaction as null.
|
||||||
|
|
||||||
// Refresh account filter options.
|
// Refresh account filter options.
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
filterByAccountComboBox.setAccounts(accounts);
|
filterByAccountComboBox.setAccounts(accounts);
|
||||||
|
@ -135,18 +138,20 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
// If a transaction id is given in the route context, navigate to the page it's on and select it.
|
// If a transaction id is given in the route context, navigate to the page it's on and select it.
|
||||||
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
|
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
searchField.setText(null);// First clear the search field if it's already populated.
|
||||||
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
Profile.getCurrent().dataSource().useRepoAsync(
|
||||||
long offset = repo.countAllAfter(tx.id);
|
TransactionRepository.class,
|
||||||
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
|
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
||||||
Platform.runLater(() -> {
|
long offset = repo.countAllAfter(tx.id);
|
||||||
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
|
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
|
||||||
});
|
Platform.runLater(() -> {
|
||||||
});
|
paginationControls.setPage(pageNumber);
|
||||||
});
|
selectedTransaction.set(tx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
paginationControls.setPage(1);
|
paginationControls.setPage(1);
|
||||||
selectedTransaction.set(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,31 +160,59 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void exportTransactions() {
|
@FXML public void exportTransactions() {
|
||||||
FileChooser fileChooser = new FileChooser();
|
Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
|
||||||
fileChooser.setTitle("Export Transactions");
|
}
|
||||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", ".csv"));
|
|
||||||
File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
|
|
||||||
if (file != null) {
|
|
||||||
try (
|
|
||||||
var repo = Profile.getCurrent().getDataSource().getTransactionRepository();
|
|
||||||
var out = new PrintWriter(file, StandardCharsets.UTF_8)
|
|
||||||
) {
|
|
||||||
out.println("id,utc-timestamp,amount,currency,description");
|
|
||||||
|
|
||||||
List<Transaction> allTransactions = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp"))).items();
|
private List<SearchFilter> getCurrentSearchFilters() {
|
||||||
for (Transaction tx : allTransactions) {
|
List<SearchFilter> filters = new ArrayList<>();
|
||||||
out.println("%d,%s,%s,%s,%s".formatted(
|
if (searchField.getText() != null && !searchField.getText().isBlank()) {
|
||||||
tx.id,
|
final String text = searchField.getText().strip();
|
||||||
tx.getTimestamp().format(DateUtil.DEFAULT_DATETIME_FORMAT),
|
// Special case: for input like "#123", search directly for the transaction id.
|
||||||
tx.getAmount().toPlainString(),
|
if (text.matches("#\\d+")) {
|
||||||
tx.getCurrency().getCurrencyCode(),
|
int idQuery = Integer.parseInt(text.substring(1));
|
||||||
tx.getDescription() == null ? "" : tx.getDescription()
|
var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build();
|
||||||
));
|
return List.of(filter);
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Popups.error("An error occurred: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
|
// Special case: for input like "tag: abc", search directly for transactions with tags like that.
|
||||||
|
if (text.matches("tag:\\s*.+")) {
|
||||||
|
String tagQuery = "%" + text.substring(4).strip().toLowerCase() + "%";
|
||||||
|
var filter = new SearchFilter.Builder().where("""
|
||||||
|
id IN (
|
||||||
|
SELECT ttj.transaction_id
|
||||||
|
FROM transaction_tag_join ttj
|
||||||
|
LEFT JOIN transaction_tag tt ON tt.id = ttj.tag_id
|
||||||
|
WHERE LOWER(tt.name) LIKE ?
|
||||||
|
)""").withArg(tagQuery).build();
|
||||||
|
return List.of(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General case: split the input into a list of terms, then apply each term in a LIKE %term% query.
|
||||||
|
var likeTerms = Arrays.stream(text.toLowerCase().split("\\s+"))
|
||||||
|
.map(t -> '%'+t+'%')
|
||||||
|
.toList();
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
List<String> orClauses = new ArrayList<>(likeTerms.size());
|
||||||
|
for (var term : likeTerms) {
|
||||||
|
orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
|
||||||
|
builder.withArg(term);
|
||||||
|
builder.withArg(term);
|
||||||
|
builder.withArg(term);
|
||||||
|
}
|
||||||
|
builder.where(String.join(" OR ", orClauses));
|
||||||
|
builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
|
||||||
|
builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
|
||||||
|
filters.add(builder.build());
|
||||||
}
|
}
|
||||||
|
if (filterByAccountComboBox.getValue() != null) {
|
||||||
|
Account filteredAccount = filterByAccountComboBox.getValue();
|
||||||
|
var filter = new SearchFilter.Builder()
|
||||||
|
.where("fae.account_id = ?")
|
||||||
|
.withArg(filteredAccount.id)
|
||||||
|
.withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
|
||||||
|
.build();
|
||||||
|
filters.add(filter);
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TransactionTile makeTile(Transaction transaction) {
|
private TransactionTile makeTile(Transaction transaction) {
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import com.andrewlalis.perfin.view.component.VendorTile;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class VendorsViewController implements RouteSelectionListener {
|
||||||
|
@FXML public VBox vendorsVBox;
|
||||||
|
private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
@FXML public void initialize() {
|
||||||
|
BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRouteSelected(Object context) {
|
||||||
|
refreshVendors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void addVendor() {
|
||||||
|
router.navigate("edit-vendor");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshVendors() {
|
||||||
|
Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
|
||||||
|
final List<TransactionVendor> vendors = repo.findAll();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
this.vendors.clear();
|
||||||
|
this.vendors.addAll(vendors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AccountEntryRepository extends Repository, AutoCloseable {
|
public interface AccountEntryRepository extends Repository, AutoCloseable {
|
||||||
long insert(
|
long insert(
|
||||||
|
@ -16,6 +17,7 @@ public interface AccountEntryRepository extends Repository, AutoCloseable {
|
||||||
AccountEntry.Type type,
|
AccountEntry.Type type,
|
||||||
Currency currency
|
Currency currency
|
||||||
);
|
);
|
||||||
|
Optional<AccountEntry> findById(long id);
|
||||||
List<AccountEntry> findAllByAccountId(long accountId);
|
List<AccountEntry> findAllByAccountId(long accountId);
|
||||||
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
|
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
package com.andrewlalis.perfin.data;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
|
|
||||||
void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
|
|
||||||
void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
|
|
||||||
void recordText(LocalDateTime timestamp, long accountId, String text);
|
|
||||||
List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
|
|
||||||
default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
|
|
||||||
var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
|
|
||||||
if (items.isEmpty()) return Optional.empty();
|
|
||||||
return Optional.of(items.getFirst());
|
|
||||||
}
|
|
||||||
String getTextItem(long itemId);
|
|
||||||
AccountEntry getAccountEntryItem(long itemId);
|
|
||||||
BalanceRecord getBalanceRecordItem(long itemId);
|
|
||||||
}
|
|
|
@ -4,30 +4,42 @@ 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;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
|
import com.andrewlalis.perfin.model.CreditCardProperties;
|
||||||
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface AccountRepository extends Repository, AutoCloseable {
|
public interface AccountRepository extends Repository, AutoCloseable {
|
||||||
long insert(AccountType type, String accountNumber, String name, Currency currency);
|
long insert(AccountType type, String accountNumber, String name, Currency currency, String description);
|
||||||
Page<Account> findAll(PageRequest pagination);
|
Page<Account> findAll(PageRequest pagination);
|
||||||
List<Account> findAllOrderedByRecentHistory();
|
List<Account> findAllOrderedByRecentHistory();
|
||||||
|
List<Account> findTopNOrderedByRecentHistory(int n);
|
||||||
|
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
|
||||||
List<Account> findAllByCurrency(Currency currency);
|
List<Account> findAllByCurrency(Currency currency);
|
||||||
Optional<Account> findById(long id);
|
Optional<Account> findById(long id);
|
||||||
void updateName(long id, String name);
|
CreditCardProperties getCreditCardProperties(long id);
|
||||||
void update(Account account);
|
void saveCreditCardProperties(CreditCardProperties properties);
|
||||||
|
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
|
||||||
void delete(Account account);
|
void delete(Account account);
|
||||||
void archive(long accountId);
|
void archive(long accountId);
|
||||||
void unarchive(long accountId);
|
void unarchive(long accountId);
|
||||||
|
|
||||||
BigDecimal deriveBalance(long accountId, Instant timestamp);
|
BigDecimal deriveCashBalance(long accountId, Instant timestamp);
|
||||||
default BigDecimal deriveCurrentBalance(long accountId) {
|
default BigDecimal deriveCurrentCashBalance(long accountId) {
|
||||||
return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
|
return deriveCashBalance(accountId, Instant.now(Clock.systemUTC()));
|
||||||
}
|
}
|
||||||
|
BigDecimal getNearestAssetValue(long accountId, Instant timestamp);
|
||||||
|
default BigDecimal getNearestAssetValue(long accountId) {
|
||||||
|
return getNearestAssetValue(accountId, Instant.now(Clock.systemUTC()));
|
||||||
|
}
|
||||||
|
|
||||||
Set<Currency> findAllUsedCurrencies();
|
Set<Currency> findAllUsedCurrencies();
|
||||||
|
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AnalyticsRepository extends Repository, AutoCloseable {
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency);
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency);
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency);
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
|
||||||
|
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the amount spent, grouped by currency, on a specific vendor.
|
||||||
|
* @param range The time range to search in.
|
||||||
|
* @param vendorId The id of the vendor to search with.
|
||||||
|
* @return A list of money values with the total amount spent in each
|
||||||
|
* currency. An empty list is returned if no money is spent.
|
||||||
|
*/
|
||||||
|
List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId);
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -11,10 +12,11 @@ import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
||||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||||
BalanceRecord findLatestByAccountId(long accountId);
|
BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
|
||||||
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findById(long id);
|
||||||
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||||
|
Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||||
List<Attachment> findAttachments(long recordId);
|
List<Attachment> findAttachments(long recordId);
|
||||||
void deleteById(long id);
|
void deleteById(long id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import javafx.application.Platform;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
@ -30,8 +31,14 @@ public interface DataSource {
|
||||||
AccountRepository getAccountRepository();
|
AccountRepository getAccountRepository();
|
||||||
BalanceRecordRepository getBalanceRecordRepository();
|
BalanceRecordRepository getBalanceRecordRepository();
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
|
TransactionVendorRepository getTransactionVendorRepository();
|
||||||
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
|
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
HistoryRepository getHistoryRepository();
|
||||||
|
SavedQueryRepository getSavedQueryRepository();
|
||||||
|
|
||||||
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
// Repository helper methods:
|
// Repository helper methods:
|
||||||
|
|
||||||
|
@ -81,8 +88,13 @@ public interface DataSource {
|
||||||
AccountRepository.class, this::getAccountRepository,
|
AccountRepository.class, this::getAccountRepository,
|
||||||
BalanceRecordRepository.class, this::getBalanceRecordRepository,
|
BalanceRecordRepository.class, this::getBalanceRecordRepository,
|
||||||
TransactionRepository.class, this::getTransactionRepository,
|
TransactionRepository.class, this::getTransactionRepository,
|
||||||
|
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||||
|
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||||
|
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
||||||
AttachmentRepository.class, this::getAttachmentRepository,
|
AttachmentRepository.class, this::getAttachmentRepository,
|
||||||
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
|
HistoryRepository.class, this::getHistoryRepository,
|
||||||
|
SavedQueryRepository.class, this::getSavedQueryRepository,
|
||||||
|
AnalyticsRepository.class, this::getAnalyticsRepository
|
||||||
);
|
);
|
||||||
return (Supplier<R>) repoSuppliers.get(type);
|
return (Supplier<R>) repoSuppliers.get(type);
|
||||||
}
|
}
|
||||||
|
@ -92,26 +104,41 @@ public interface DataSource {
|
||||||
default CompletableFuture<String> getAccountBalanceText(Account account) {
|
default CompletableFuture<String> getAccountBalanceText(Account account) {
|
||||||
CompletableFuture<String> cf = new CompletableFuture<>();
|
CompletableFuture<String> cf = new CompletableFuture<>();
|
||||||
mapRepoAsync(AccountRepository.class, repo -> {
|
mapRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
|
||||||
MoneyValue money = new MoneyValue(balance, account.getCurrency());
|
MoneyValue money = new MoneyValue(balance, account.getCurrency());
|
||||||
return CurrencyUtil.formatMoney(money);
|
return CurrencyUtil.formatMoney(money);
|
||||||
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
|
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
|
||||||
return cf;
|
return cf;
|
||||||
}
|
}
|
||||||
|
|
||||||
default Map<Currency, BigDecimal> getCombinedAccountBalances() {
|
/**
|
||||||
try (var accountRepo = getAccountRepository()) {
|
* Gets a list of combined total assets for each currency that's tracked,
|
||||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
|
* ordered with highest assets first.
|
||||||
|
* @param timestamp The timestamp at which to get the balance.
|
||||||
|
* @return A future that resolves to the list of amounts for each currency.
|
||||||
|
*/
|
||||||
|
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances(Instant timestamp) {
|
||||||
|
return mapRepoAsync(AccountRepository.class, repo -> {
|
||||||
|
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
|
||||||
Map<Currency, BigDecimal> totals = new HashMap<>();
|
Map<Currency, BigDecimal> totals = new HashMap<>();
|
||||||
for (var account : accounts) {
|
for (var account : accounts) {
|
||||||
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
|
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
|
||||||
BigDecimal accountBalance = accountRepo.deriveCurrentBalance(account.id);
|
BigDecimal accountBalance = repo.deriveCashBalance(account.id, timestamp);
|
||||||
|
BigDecimal accountAssetsValue = repo.getNearestAssetValue(account.id, timestamp);
|
||||||
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
|
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
|
||||||
totals.put(account.getCurrency(), currencyTotal.add(accountBalance));
|
BigDecimal accountTotal = accountBalance.add(accountAssetsValue);
|
||||||
|
totals.put(account.getCurrency(), currencyTotal.add(accountTotal));
|
||||||
}
|
}
|
||||||
return totals;
|
List<MoneyValue> values = new ArrayList<>(totals.size());
|
||||||
} catch (Exception e) {
|
for (var entry : totals.entrySet()) {
|
||||||
throw new RuntimeException(e);
|
values.add(new MoneyValue(entry.getValue(), entry.getKey()));
|
||||||
}
|
}
|
||||||
|
values.sort((m1, m2) -> m2.amount().compareTo(m1.amount()));
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() {
|
||||||
|
return getCombinedAccountBalances(Instant.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that defines the data source factory, a component responsible for
|
||||||
|
* obtaining a data source, and performing some introspection around that data
|
||||||
|
* source before one is obtained.
|
||||||
|
*/
|
||||||
|
public interface DataSourceFactory {
|
||||||
|
DataSource getDataSource(String profileName) throws ProfileLoadException;
|
||||||
|
|
||||||
|
enum SchemaStatus {
|
||||||
|
UP_TO_DATE,
|
||||||
|
NEEDS_MIGRATION,
|
||||||
|
INCOMPATIBLE
|
||||||
|
}
|
||||||
|
SchemaStatus getSchemaStatus(String profileName) throws IOException;
|
||||||
|
|
||||||
|
int getSchemaVersion(String profileName) throws IOException;
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface HistoryRepository extends Repository, AutoCloseable {
|
||||||
|
long getOrCreateHistoryForAccount(long accountId);
|
||||||
|
long getOrCreateHistoryForTransaction(long transactionId);
|
||||||
|
void deleteHistoryForAccount(long accountId);
|
||||||
|
void deleteHistoryForTransaction(long transactionId);
|
||||||
|
|
||||||
|
HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
|
||||||
|
default HistoryTextItem addTextItem(long historyId, String description) {
|
||||||
|
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
|
||||||
|
}
|
||||||
|
Optional<HistoryItem> getItem(long id);
|
||||||
|
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
|
||||||
|
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
|
||||||
|
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
|
||||||
|
return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public class SampleProfileGenerator {
|
||||||
|
private final ProfileLoader profileLoader;
|
||||||
|
|
||||||
|
private final Random random;
|
||||||
|
|
||||||
|
public SampleProfileGenerator(ProfileLoader profileLoader) {
|
||||||
|
this.profileLoader = profileLoader;
|
||||||
|
this.random = new Random();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Profile createSampleProfile() throws ProfileLoadException, SQLException, IOException {
|
||||||
|
String name = getNewSampleProfileName();
|
||||||
|
Profile profile = profileLoader.load(name);
|
||||||
|
generateRandomAccounts(profile);
|
||||||
|
generateBrokerageAccountAssetRecords(profile);
|
||||||
|
generateRandomTransactions(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getNewSampleProfileName() {
|
||||||
|
int i = 1;
|
||||||
|
while (true) {
|
||||||
|
String name = "sample-" + i;
|
||||||
|
if (Files.notExists(Profile.getDir(name))) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateRandomAccounts(Profile profile) {
|
||||||
|
final int accountsToCreate = random.nextInt(5, 11);
|
||||||
|
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||||
|
BalanceRecordRepository balanceRecordRepo = profile.dataSource().getBalanceRecordRepository();
|
||||||
|
for (int i = 0; i < accountsToCreate; i++) {
|
||||||
|
long id = accountRepo.insert(
|
||||||
|
randomChoice(AccountType.values()),
|
||||||
|
randomAccountNumber(),
|
||||||
|
"Sample Account " + i,
|
||||||
|
randomChoice(Currency.getInstance("USD"), Currency.getInstance("EUR")),
|
||||||
|
"Description for sample account " + i + "."
|
||||||
|
);
|
||||||
|
Account account = accountRepo.findById(id).orElseThrow();
|
||||||
|
BigDecimal initialBalance = randomMoneyValue(account.getCurrency(), 0, 5000, true);
|
||||||
|
if (account.getType() == AccountType.CREDIT_CARD) {
|
||||||
|
BigDecimal creditLimit = randomMoneyValue(account.getCurrency(), 200, 10000, false);
|
||||||
|
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
|
||||||
|
}
|
||||||
|
balanceRecordRepo.insert(DateUtil.nowAsUTC(), account.id, BalanceRecordType.CASH, initialBalance, account.getCurrency(), Collections.emptyList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateBrokerageAccountAssetRecords(Profile profile) {
|
||||||
|
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||||
|
BalanceRecordRepository balanceRecordRepo = profile.dataSource().getBalanceRecordRepository();
|
||||||
|
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
|
||||||
|
for (var account : accounts) {
|
||||||
|
if (account.getType() == AccountType.BROKERAGE) {
|
||||||
|
LocalDateTime cutoff = account.getCreatedAt().minusYears(5);
|
||||||
|
LocalDateTime currentTimestamp = account.getCreatedAt().minusDays(random.nextInt(1, 30));
|
||||||
|
BigDecimal assetValue = randomMoneyValue(account.getCurrency(), 1000, 1_000_000, true);
|
||||||
|
while (currentTimestamp.isAfter(cutoff)) {
|
||||||
|
balanceRecordRepo.insert(
|
||||||
|
currentTimestamp,
|
||||||
|
account.id,
|
||||||
|
BalanceRecordType.ASSETS,
|
||||||
|
assetValue,
|
||||||
|
account.getCurrency(),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
double valueAdjustment = random.nextGaussian() * assetValue.doubleValue() / 100.0 - 0.2;
|
||||||
|
assetValue = assetValue.subtract(BigDecimal.valueOf(valueAdjustment)).setScale(4, RoundingMode.HALF_UP);
|
||||||
|
currentTimestamp = currentTimestamp.minusDays(random.nextInt(7, 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateRandomTransactions(Profile profile) {
|
||||||
|
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||||
|
TransactionRepository transactionRepo = profile.dataSource().getTransactionRepository();
|
||||||
|
TransactionVendorRepository vendorRepo = profile.dataSource().getTransactionVendorRepository();
|
||||||
|
TransactionCategoryRepository categoryRepo = profile.dataSource().getTransactionCategoryRepository();
|
||||||
|
final int vendorCount = 50;
|
||||||
|
for (int i = 0; i < vendorCount; i++) {
|
||||||
|
vendorRepo.insert("Vendor " + i);
|
||||||
|
}
|
||||||
|
List<String> vendors = vendorRepo.findAll().stream().map(TransactionVendor::getName).toList();
|
||||||
|
final int tagCount = 10;
|
||||||
|
List<String> tags = new ArrayList<>(tagCount);
|
||||||
|
for (int i = 0; i < tagCount; i++) {
|
||||||
|
tags.add("tag-" + i);
|
||||||
|
}
|
||||||
|
List<String> categories = categoryRepo.findAll().stream().map(TransactionCategory::getName).toList();
|
||||||
|
|
||||||
|
for (var account : accountRepo.findAll(PageRequest.unpaged()).items()) {
|
||||||
|
LocalDateTime cutoff = account.getCreatedAt().minusMonths(3);
|
||||||
|
LocalDateTime timestamp = account.getCreatedAt().minusSeconds(random.nextInt(60, 60*60*24));
|
||||||
|
while (timestamp.isAfter(cutoff)) {
|
||||||
|
String vendor = null;
|
||||||
|
if (randomChance(0.75)) {
|
||||||
|
vendor = randomChoice(vendors);
|
||||||
|
}
|
||||||
|
String category = null;
|
||||||
|
if (randomChance(0.8)) {
|
||||||
|
category = randomChoice(categories);
|
||||||
|
}
|
||||||
|
Set<String> tagsToUse = new HashSet<>();
|
||||||
|
if (randomChance(0.75)) {
|
||||||
|
for (int i = 0; i < random.nextInt(3); i++) {
|
||||||
|
tagsToUse.add(randomChoice(tags));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BigDecimal transactionAmount = randomMoneyValue(account.getCurrency(), 1, 500, true);
|
||||||
|
CreditAndDebitAccounts accounts = new CreditAndDebitAccounts(account, null);
|
||||||
|
if (randomChance(0.1)) {
|
||||||
|
accounts = new CreditAndDebitAccounts(null, account);
|
||||||
|
transactionAmount = randomMoneyValue(account.getCurrency(), 500, 2000, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionRepo.insert(
|
||||||
|
timestamp,
|
||||||
|
transactionAmount,
|
||||||
|
account.getCurrency(),
|
||||||
|
"Sample transaction description.",
|
||||||
|
accounts,
|
||||||
|
vendor,
|
||||||
|
category,
|
||||||
|
tagsToUse,
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
|
||||||
|
timestamp = timestamp.minusSeconds(random.nextInt(60, 60*60*24 * 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal randomMoneyValue(Currency currency, int min, int max, boolean includeDecimals) {
|
||||||
|
int wholeValue = random.nextInt(min, max + 1);
|
||||||
|
BigDecimal value = BigDecimal.valueOf(wholeValue * 10000L, 4);
|
||||||
|
if (includeDecimals && currency.getDefaultFractionDigits() > 0) {
|
||||||
|
int orderOfMagnitude = (int) Math.pow(10, currency.getDefaultFractionDigits());
|
||||||
|
int decimalValue = random.nextInt( orderOfMagnitude + 1);
|
||||||
|
BigDecimal fractionalValue = BigDecimal.valueOf(decimalValue, currency.getDefaultFractionDigits());
|
||||||
|
value = value.add(fractionalValue);
|
||||||
|
}
|
||||||
|
return value.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String randomAccountNumber() {
|
||||||
|
String alphabet = "0123456789";
|
||||||
|
StringBuilder sb = new StringBuilder(16);
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
sb.append(alphabet.charAt(random.nextInt(alphabet.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private <T> T randomChoice(T... items) {
|
||||||
|
return items[random.nextInt(items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T randomChoice(List<T> items) {
|
||||||
|
return items.get(random.nextInt(items.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean randomChance(double percentChance) {
|
||||||
|
return random.nextDouble() <= percentChance;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface SavedQueryRepository extends Repository {
|
||||||
|
List<String> getSavedQueries();
|
||||||
|
String getSavedQueryContent(String name);
|
||||||
|
void createSavedQuery(String name, String content);
|
||||||
|
void deleteSavedQuery(String name);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
public record TimestampRange(LocalDateTime start, LocalDateTime end) {
|
||||||
|
public static TimestampRange lastNDays(int days) {
|
||||||
|
LocalDateTime now = DateUtil.nowAsUTC();
|
||||||
|
return new TimestampRange(now.minusDays(days), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TimestampRange lastNMonths(int months) {
|
||||||
|
LocalDateTime now = DateUtil.nowAsUTC();
|
||||||
|
return new TimestampRange(now.minusMonths(months), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TimestampRange thisMonth() {
|
||||||
|
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
||||||
|
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime();
|
||||||
|
return new TimestampRange(utcStart, DateUtil.nowAsUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TimestampRange thisYear() {
|
||||||
|
LocalDateTime utcStart = LocalDate.now(ZoneId.systemDefault())
|
||||||
|
.withDayOfYear(1)
|
||||||
|
.atStartOfDay()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime();
|
||||||
|
return new TimestampRange(utcStart, DateUtil.nowAsUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TimestampRange unbounded() {
|
||||||
|
LocalDateTime now = DateUtil.nowAsUTC();
|
||||||
|
return new TimestampRange(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), now);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
|
||||||
|
Optional<TransactionCategory> findById(long id);
|
||||||
|
Optional<TransactionCategory> findByName(String name);
|
||||||
|
List<TransactionCategory> findAllBaseCategories();
|
||||||
|
List<TransactionCategory> findAll();
|
||||||
|
TransactionCategory findRoot(long categoryId);
|
||||||
|
long insert(long parentId, String name, Color color);
|
||||||
|
long insert(String name, Color color);
|
||||||
|
void update(long id, String name, Color color);
|
||||||
|
void deleteById(long id);
|
||||||
|
|
||||||
|
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children) {
|
||||||
|
public Set<Long> allIds() {
|
||||||
|
Set<Long> ids = new HashSet<>();
|
||||||
|
ids.add(category.id);
|
||||||
|
for (var child : children) {
|
||||||
|
ids.addAll(child.allIds());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CategoryTreeNode> findTree();
|
||||||
|
CategoryTreeNode findTree(TransactionCategory root);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface TransactionLineItemRepository extends Repository, AutoCloseable {
|
||||||
|
List<TransactionLineItem> findItems(long transactionId);
|
||||||
|
List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items);
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -21,16 +22,28 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
);
|
);
|
||||||
Optional<Transaction> findById(long id);
|
Optional<Transaction> findById(long id);
|
||||||
Page<Transaction> findAll(PageRequest pagination);
|
Page<Transaction> findAll(PageRequest pagination);
|
||||||
|
List<Transaction> findRecentN(int n);
|
||||||
|
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
|
||||||
long countAll();
|
long countAll();
|
||||||
long countAllAfter(long transactionId);
|
long countAllAfter(long transactionId);
|
||||||
long countAllByAccounts(Set<Long> accountIds);
|
long countAllByAccounts(Set<Long> accountIds);
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
|
Optional<Transaction> findEarliest();
|
||||||
|
Optional<Transaction> findLatest();
|
||||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||||
List<Attachment> findAttachments(long transactionId);
|
List<Attachment> findAttachments(long transactionId);
|
||||||
|
List<String> findTags(long transactionId);
|
||||||
|
List<String> findAllTags();
|
||||||
|
void deleteTag(String name);
|
||||||
|
long countTagUsages(String name);
|
||||||
void delete(long transactionId);
|
void delete(long transactionId);
|
||||||
void update(
|
void update(
|
||||||
long id,
|
long id,
|
||||||
|
@ -39,6 +52,10 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TransactionVendorRepository extends Repository, AutoCloseable {
|
||||||
|
Optional<TransactionVendor> findById(long id);
|
||||||
|
Optional<TransactionVendor> findByName(String name);
|
||||||
|
List<TransactionVendor> findAll();
|
||||||
|
long insert(String name, String description);
|
||||||
|
long insert(String name);
|
||||||
|
void update(long id, String name, String description);
|
||||||
|
void deleteById(long id);
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.SavedQueryRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record FileSystemSavedQueryRepository(Path contentDir) implements SavedQueryRepository {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FileSystemSavedQueryRepository.class);
|
||||||
|
|
||||||
|
private Path queriesDir() {
|
||||||
|
return contentDir.resolve("saved-queries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path queryFile(String name) {
|
||||||
|
return queriesDir().resolve(name + ".sql");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getSavedQueries() {
|
||||||
|
Path dir = queriesDir();
|
||||||
|
if (Files.notExists(dir)) return Collections.emptyList();
|
||||||
|
try (var stream = Files.list(dir)) {
|
||||||
|
return stream.filter(p ->
|
||||||
|
Files.isRegularFile(p) &&
|
||||||
|
p.getFileName().toString().toLowerCase().endsWith(".sql")
|
||||||
|
)
|
||||||
|
.map(p -> {
|
||||||
|
var s = p.getFileName().toString();
|
||||||
|
int idx = s.lastIndexOf('.');
|
||||||
|
return s.substring(0, idx);
|
||||||
|
})
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to list files", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getSavedQueryContent(String name) {
|
||||||
|
Path file = queryFile(name);
|
||||||
|
if (Files.notExists(file)) return null;
|
||||||
|
try {
|
||||||
|
return Files.readString(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read saved query content", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createSavedQuery(String name, String content) {
|
||||||
|
try {
|
||||||
|
if (Files.notExists(queriesDir())) {
|
||||||
|
Files.createDirectory(queriesDir());
|
||||||
|
}
|
||||||
|
Path file = queryFile(name);
|
||||||
|
Files.writeString(file, content);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to create saved query.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteSavedQuery(String name) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(queryFile(name));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to delete saved query.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
|
||||||
|
@ -12,11 +11,12 @@ import java.sql.SQLException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
|
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
|
||||||
long entryId = DbUtil.insertOne(
|
return DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"""
|
"""
|
||||||
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
|
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
|
||||||
|
@ -30,10 +30,16 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
|
||||||
currency.getCurrencyCode()
|
currency.getCurrencyCode()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// Insert an entry into the account's history.
|
}
|
||||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
|
||||||
historyRepo.recordAccountEntry(timestamp, accountId, entryId);
|
@Override
|
||||||
return entryId;
|
public Optional<AccountEntry> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM account_entry WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcAccountEntryRepository::parse
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -1,120 +0,0 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
|
|
||||||
@Override
|
|
||||||
public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
|
|
||||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
|
|
||||||
DbUtil.insertOne(
|
|
||||||
conn,
|
|
||||||
"INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
|
|
||||||
List.of(itemId, entryId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
|
|
||||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
|
|
||||||
DbUtil.insertOne(
|
|
||||||
conn,
|
|
||||||
"INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
|
|
||||||
List.of(itemId, recordId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void recordText(LocalDateTime timestamp, long accountId, String text) {
|
|
||||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
|
|
||||||
DbUtil.insertOne(
|
|
||||||
conn,
|
|
||||||
"INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
|
|
||||||
List.of(itemId, text)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
|
|
||||||
return DbUtil.findAll(
|
|
||||||
conn,
|
|
||||||
"SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
|
|
||||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
|
||||||
JdbcAccountHistoryItemRepository::parseHistoryItem
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getTextItem(long itemId) {
|
|
||||||
return DbUtil.findOne(
|
|
||||||
conn,
|
|
||||||
"SELECT description FROM account_history_item_text WHERE item_id = ?",
|
|
||||||
List.of(itemId),
|
|
||||||
rs -> rs.getString(1)
|
|
||||||
).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AccountEntry getAccountEntryItem(long itemId) {
|
|
||||||
return DbUtil.findOne(
|
|
||||||
conn,
|
|
||||||
"""
|
|
||||||
SELECT *
|
|
||||||
FROM account_entry
|
|
||||||
LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
|
|
||||||
WHERE h.item_id = ?""",
|
|
||||||
List.of(itemId),
|
|
||||||
JdbcAccountEntryRepository::parse
|
|
||||||
).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public BalanceRecord getBalanceRecordItem(long itemId) {
|
|
||||||
return DbUtil.findOne(
|
|
||||||
conn,
|
|
||||||
"""
|
|
||||||
SELECT *
|
|
||||||
FROM balance_record
|
|
||||||
LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
|
|
||||||
WHERE h.item_id = ?""",
|
|
||||||
List.of(itemId),
|
|
||||||
JdbcBalanceRecordRepository::parse
|
|
||||||
).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws Exception {
|
|
||||||
conn.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
|
|
||||||
return new AccountHistoryItem(
|
|
||||||
rs.getLong("id"),
|
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
|
||||||
rs.getLong("account_id"),
|
|
||||||
AccountHistoryItemType.valueOf(rs.getString("type"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
|
||||||
return DbUtil.insertOne(
|
|
||||||
conn,
|
|
||||||
"INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
|
|
||||||
List.of(
|
|
||||||
DbUtil.timestampFromUtcLDT(timestamp),
|
|
||||||
accountId,
|
|
||||||
type.name()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +1,11 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.*;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
|
||||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
|
||||||
import com.andrewlalis.perfin.data.EntityNotFoundException;
|
|
||||||
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.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -29,22 +23,26 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
|
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
|
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
long accountId = DbUtil.insertOne(
|
long accountId = DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
List.of(
|
DbUtil.timestampFromUtcNow(),
|
||||||
DbUtil.timestampFromUtcNow(),
|
type.name(),
|
||||||
type.name(),
|
accountNumber,
|
||||||
accountNumber,
|
name,
|
||||||
name,
|
currency.getCurrencyCode(),
|
||||||
currency.getCurrencyCode()
|
description
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
// If it's a credit card account, preemptively create a credit card properties record.
|
||||||
|
if (type == AccountType.CREDIT_CARD) {
|
||||||
|
saveCreditCardProperties(new CreditCardProperties(accountId, null));
|
||||||
|
}
|
||||||
// Insert a history item indicating the creation of the account.
|
// Insert a history item indicating the creation of the account.
|
||||||
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||||
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
|
||||||
return accountId;
|
return accountId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -59,11 +57,46 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
return DbUtil.findAll(
|
return DbUtil.findAll(
|
||||||
conn,
|
conn,
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _
|
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
|
||||||
FROM account
|
FROM account
|
||||||
LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id
|
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
|
||||||
|
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
|
||||||
WHERE NOT account.archived
|
WHERE NOT account.archived
|
||||||
ORDER BY ahi.timestamp DESC, account.created_at DESC""",
|
ORDER BY hi.timestamp DESC, account.created_at DESC""",
|
||||||
|
JdbcAccountRepository::parseAccount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Account> findTopNOrderedByRecentHistory(int n) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
|
||||||
|
FROM account
|
||||||
|
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
|
||||||
|
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
|
||||||
|
WHERE NOT account.archived
|
||||||
|
ORDER BY hi.timestamp DESC, account.created_at DESC
|
||||||
|
LIMIT\s""" + n,
|
||||||
|
JdbcAccountRepository::parseAccount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive) {
|
||||||
|
LocalDateTime cutoff = DateUtil.nowAsUTC().minusDays(daysSinceLastActive);
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
|
||||||
|
FROM account
|
||||||
|
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
|
||||||
|
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
|
||||||
|
WHERE NOT account.archived AND hi.timestamp >= ?
|
||||||
|
ORDER BY hi.timestamp DESC, account.created_at DESC
|
||||||
|
LIMIT\s""" + n,
|
||||||
|
List.of(DbUtil.timestampFromUtcLDT(cutoff)),
|
||||||
JdbcAccountRepository::parseAccount
|
JdbcAccountRepository::parseAccount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -84,12 +117,49 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateName(long id, String name) {
|
public CreditCardProperties getCreditCardProperties(long id) {
|
||||||
DbUtil.updateOne(conn, "UPDATE account SET name = ? WHERE id = ? AND NOT archived", List.of(name, id));
|
AccountType accountType = getAccountType(id);
|
||||||
|
if (accountType != AccountType.CREDIT_CARD) return null;
|
||||||
|
Optional<CreditCardProperties> optionalProperties = DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM credit_card_account_properties WHERE account_id = ?",
|
||||||
|
List.of(id),
|
||||||
|
JdbcAccountRepository::parseCreditCardProperties
|
||||||
|
);
|
||||||
|
if (optionalProperties.isPresent()) return optionalProperties.get();
|
||||||
|
// No properties were found for the credit card account, so create an empty properties.
|
||||||
|
CreditCardProperties defaultProperties = new CreditCardProperties(id, null);
|
||||||
|
saveCreditCardProperties(defaultProperties);
|
||||||
|
return defaultProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BigDecimal deriveBalance(long accountId, Instant timestamp) {
|
public void saveCreditCardProperties(CreditCardProperties properties) {
|
||||||
|
AccountType accountType = getAccountType(properties.accountId());
|
||||||
|
if (accountType != AccountType.CREDIT_CARD) return;
|
||||||
|
CreditCardProperties existingProperties = DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM credit_card_account_properties WHERE account_id = ?",
|
||||||
|
List.of(properties.accountId()),
|
||||||
|
JdbcAccountRepository::parseCreditCardProperties
|
||||||
|
).orElse(null);
|
||||||
|
if (existingProperties != null) {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"UPDATE credit_card_account_properties SET credit_limit = ? WHERE account_id = ?",
|
||||||
|
properties.creditLimit(), properties.accountId()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO credit_card_account_properties (account_id, credit_limit) VALUES (?, ?)",
|
||||||
|
properties.accountId(), properties.creditLimit()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal deriveCashBalance(long accountId, Instant timestamp) {
|
||||||
// First find the account itself, since its properties influence the balance.
|
// First find the account itself, since its properties influence the balance.
|
||||||
Account account = findById(accountId).orElse(null);
|
Account account = findById(accountId).orElse(null);
|
||||||
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
|
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
|
||||||
|
@ -97,7 +167,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||||
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
// Find the most recent balance record before timestamp.
|
// Find the most recent balance record before timestamp.
|
||||||
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
|
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp);
|
||||||
if (closestPastRecord.isPresent()) {
|
if (closestPastRecord.isPresent()) {
|
||||||
// Then find any entries on the account since that balance record and the timestamp.
|
// Then find any entries on the account since that balance record and the timestamp.
|
||||||
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
||||||
|
@ -108,7 +178,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
||||||
} else {
|
} else {
|
||||||
// There is no balance record present before the given timestamp. Try and find the closest one after.
|
// There is no balance record present before the given timestamp. Try and find the closest one after.
|
||||||
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
|
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp);
|
||||||
if (closestFutureRecord.isPresent()) {
|
if (closestFutureRecord.isPresent()) {
|
||||||
// Now find any entries on the account from the timestamp until that balance record.
|
// Now find any entries on the account from the timestamp until that balance record.
|
||||||
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
||||||
|
@ -120,13 +190,22 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
} else {
|
} else {
|
||||||
// No balance records exist for the account! Assume balance of 0 when the account was created.
|
// No balance records exist for the account! Assume balance of 0 when the account was created.
|
||||||
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
|
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
|
||||||
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BigDecimal.ZERO, account.getCurrency());
|
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency());
|
||||||
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
|
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
|
||||||
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
|
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getNearestAssetValue(long accountId, Instant timestamp) {
|
||||||
|
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
|
||||||
|
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||||
|
Optional<BalanceRecord> mostRecentRecord = balanceRecordRepo.findClosestBefore(accountId, BalanceRecordType.ASSETS, utcTimestamp);
|
||||||
|
if (mostRecentRecord.isEmpty()) return BigDecimal.ZERO;
|
||||||
|
return mostRecentRecord.get().getBalance();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<Currency> findAllUsedCurrencies() {
|
public Set<Currency> findAllUsedCurrencies() {
|
||||||
return new HashSet<>(DbUtil.findAll(
|
return new HashSet<>(DbUtil.findAll(
|
||||||
|
@ -137,30 +216,102 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(Account account) {
|
public List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults) {
|
||||||
DbUtil.updateOne(
|
var entryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
conn,
|
var historyRepo = new JdbcHistoryRepository(conn);
|
||||||
"UPDATE account SET name = ?, account_number = ?, currency = ?, account_type = ? WHERE id = ?",
|
var balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||||
List.of(
|
String query = """
|
||||||
account.getName(),
|
SELECT id, type
|
||||||
account.getAccountNumber(),
|
FROM (
|
||||||
account.getCurrency().getCurrencyCode(),
|
SELECT id, timestamp, 'ACCOUNT_ENTRY' AS type, account_id
|
||||||
account.getType().name(),
|
FROM account_entry
|
||||||
account.id
|
UNION ALL
|
||||||
|
SELECT id, timestamp, 'HISTORY_ITEM' AS type, account_id
|
||||||
|
FROM history_item
|
||||||
|
LEFT JOIN history_account ha ON history_item.history_id = ha.history_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
|
||||||
|
FROM balance_record
|
||||||
)
|
)
|
||||||
);
|
WHERE account_id = ? AND timestamp < ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT\s""" + maxResults;
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
stmt.setLong(1, accountId);
|
||||||
|
stmt.setTimestamp(2, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||||
|
ResultSet rs = stmt.executeQuery();
|
||||||
|
List<Timestamped> entities = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
long id = rs.getLong(1);
|
||||||
|
String type = rs.getString(2);
|
||||||
|
Timestamped entity = switch (type) {
|
||||||
|
case "HISTORY_ITEM" -> historyRepo.getItem(id).orElse(null);
|
||||||
|
case "ACCOUNT_ENTRY" -> entryRepo.findById(id).orElse(null);
|
||||||
|
case "BALANCE_RECORD" -> balanceRecordRepo.findById(id).orElse(null);
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (entity == null) {
|
||||||
|
log.warn("Failed to find entity with id {} and type {}.", id, type);
|
||||||
|
} else {
|
||||||
|
entities.add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
log.error("Failed to find account events.", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
Account account = findById(accountId).orElse(null);
|
||||||
|
if (account == null) return;
|
||||||
|
List<String> updateMessages = new ArrayList<>();
|
||||||
|
if (account.getType() != type) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET account_type = ? WHERE id = ?", type.name(), accountId);
|
||||||
|
updateMessages.add(String.format("Updated account type from %s to %s.", account.getType(), type));
|
||||||
|
}
|
||||||
|
if (!account.getAccountNumber().equals(accountNumber)) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET account_number = ? WHERE id = ?", accountNumber, accountId);
|
||||||
|
updateMessages.add(String.format("Updated account number from %s to %s.", account.getAccountNumber(), accountNumber));
|
||||||
|
}
|
||||||
|
if (!account.getName().equals(name)) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET name = ? WHERE id = ?", name, accountId);
|
||||||
|
updateMessages.add(String.format("Updated account name from \"%s\" to \"%s\".", account.getName(), name));
|
||||||
|
}
|
||||||
|
if (account.getCurrency() != currency) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET currency = ? WHERE id = ?", currency.getCurrencyCode(), accountId);
|
||||||
|
updateMessages.add(String.format("Updated account currency from %s to %s.", account.getCurrency(), currency));
|
||||||
|
}
|
||||||
|
if (!Objects.equals(account.getDescription(), description)) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET description = ? WHERE id = ?", description, accountId);
|
||||||
|
updateMessages.add("Updated account's description.");
|
||||||
|
}
|
||||||
|
if (!updateMessages.isEmpty()) {
|
||||||
|
var historyRepo = new JdbcHistoryRepository(conn);
|
||||||
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
historyRepo.addTextItem(historyId, String.join("\n", updateMessages));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(Account account) {
|
public void delete(Account account) {
|
||||||
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id));
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
DbUtil.update(conn, "DELETE FROM credit_card_account_properties WHERE account_id = ?", account.id);
|
||||||
|
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", account.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void archive(long accountId) {
|
public void archive(long accountId) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
|
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
|
||||||
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived.");
|
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||||
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
historyRepo.addTextItem(historyId, "Account has been archived.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,7 +319,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
public void unarchive(long accountId) {
|
public void unarchive(long accountId) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
|
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
|
||||||
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived.");
|
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||||
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
historyRepo.addTextItem(historyId, "Account has been unarchived.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,7 +333,14 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
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, archived, type, accountNumber, name, currency);
|
String description = rs.getString("description");
|
||||||
|
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CreditCardProperties parseCreditCardProperties(ResultSet rs) throws SQLException {
|
||||||
|
long accountId = rs.getLong("account_id");
|
||||||
|
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
|
||||||
|
return new CreditCardProperties(accountId, creditLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -204,4 +364,11 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AccountType getAccountType(long id) {
|
||||||
|
String accountTypeStr = DbUtil.findOne(conn, "SELECT account_type FROM account WHERE id = ?", List.of(id), rs -> rs.getString(1))
|
||||||
|
.orElse(null);
|
||||||
|
if (accountTypeStr == null) return null;
|
||||||
|
return AccountType.valueOf(accountTypeStr.toUpperCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.AnalyticsRepository;
|
||||||
|
import com.andrewlalis.perfin.data.TimestampRange;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepository {
|
||||||
|
@Override
|
||||||
|
public List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency) {
|
||||||
|
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.CREDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency) {
|
||||||
|
return groupByRootCategory(getSpendByCategory(range, currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency) {
|
||||||
|
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.DEBIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency) {
|
||||||
|
return groupByRootCategory(getIncomeByCategory(range, currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(transaction.amount) AS total,
|
||||||
|
tv.id, tv.name, tv.description
|
||||||
|
FROM transaction
|
||||||
|
LEFT JOIN transaction_vendor tv ON tv.id = transaction.vendor_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
|
||||||
|
WHERE
|
||||||
|
transaction.currency = ? AND
|
||||||
|
transaction.timestamp >= ? AND
|
||||||
|
transaction.timestamp <= ? AND
|
||||||
|
ae.type = 'CREDIT' AND
|
||||||
|
'!exclude' NOT IN (
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
|
WHERE ttj.transaction_id = transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY tv.id
|
||||||
|
ORDER BY total DESC""",
|
||||||
|
List.of(currency.getCurrencyCode(), range.start(), range.end()),
|
||||||
|
rs -> {
|
||||||
|
BigDecimal total = rs.getBigDecimal(1);
|
||||||
|
long vendorId = rs.getLong(2);
|
||||||
|
if (rs.wasNull()) return new Pair<>(null, total);
|
||||||
|
String name = rs.getString(3);
|
||||||
|
String description = rs.getString(4);
|
||||||
|
return new Pair<>(new TransactionVendor(vendorId, name, description), total);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(transaction.amount) AS total,
|
||||||
|
transaction.currency AS currency,
|
||||||
|
FROM transaction
|
||||||
|
WHERE
|
||||||
|
transaction.vendor_id = ? AND
|
||||||
|
transaction.timestamp >= ? AND
|
||||||
|
transaction.timestamp <= ? AND
|
||||||
|
'!exclude' NOT IN (
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
|
WHERE ttj.transaction_id = transaction.id
|
||||||
|
) AND
|
||||||
|
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id) = 1 AND
|
||||||
|
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id AND ae.type = 'CREDIT') = 1
|
||||||
|
GROUP BY transaction.currency
|
||||||
|
ORDER BY total DESC""",
|
||||||
|
List.of(vendorId, range.start(), range.end()),
|
||||||
|
rs -> {
|
||||||
|
BigDecimal total = rs.getBigDecimal(1);
|
||||||
|
String currencyCode = rs.getString(2);
|
||||||
|
return new MoneyValue(total, Currency.getInstance(currencyCode));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Pair<TransactionCategory, BigDecimal>> getTransactionAmountByCategoryAndType(TimestampRange range, Currency currency, AccountEntry.Type type) {
|
||||||
|
// First find totals for each category, using only transactions without any line items (should be most).
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsBeforeLineItems = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(transaction.amount) AS total,
|
||||||
|
tc.id, tc.parent_id, tc.name, tc.color
|
||||||
|
FROM transaction
|
||||||
|
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
|
||||||
|
WHERE
|
||||||
|
transaction.currency = ? AND
|
||||||
|
ae.type = ? AND
|
||||||
|
transaction.timestamp >= ? AND
|
||||||
|
transaction.timestamp <= ? AND
|
||||||
|
'!exclude' NOT IN (
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
|
WHERE ttj.transaction_id = transaction.id
|
||||||
|
) AND
|
||||||
|
(
|
||||||
|
SELECT COUNT(tli.id) = 0
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
WHERE tli.transaction_id = transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY tc.id
|
||||||
|
ORDER BY total DESC;""",
|
||||||
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
|
this::parseAmountAndCategory
|
||||||
|
);
|
||||||
|
// Then augment the data for any transactions which do have line items.
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsFromLineItemsOnly = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT SUM(tli.value_per_item * tli.quantity) AS s, tc.*
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
LEFT JOIN transaction_category tc ON tc.id = tli.category_id
|
||||||
|
LEFT JOIN transaction t ON t.id = tli.transaction_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = t.id
|
||||||
|
WHERE
|
||||||
|
t.currency = ? AND
|
||||||
|
ae.type = ? AND
|
||||||
|
t.timestamp >= ? AND
|
||||||
|
t.timestamp <= ? AND
|
||||||
|
'!exclude' NOT IN (
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
|
WHERE ttj.transaction_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY tli.category_id
|
||||||
|
ORDER BY s DESC""",
|
||||||
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
|
this::parseAmountAndCategory
|
||||||
|
);
|
||||||
|
// Finally add data for any remaining value in transactions with line items, which wasn't accounted for in line items.
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsFromLeftoverTransactions = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT SUM(s), c_id, c_parent_id, c_name, c_color
|
||||||
|
FROM (
|
||||||
|
SELECT transaction.amount - SUM(tli.value_per_item * tli.quantity) AS s,
|
||||||
|
tc.id AS c_id, tc.parent_id AS c_parent_id, tc.name AS c_name, tc.color AS c_color
|
||||||
|
FROM transaction
|
||||||
|
LEFT JOIN transaction_line_item tli ON tli.transaction_id = transaction.id
|
||||||
|
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
|
||||||
|
WHERE
|
||||||
|
transaction.currency = ? AND
|
||||||
|
ae.type = ? AND
|
||||||
|
transaction.timestamp >= ? AND
|
||||||
|
transaction.timestamp <= ? AND
|
||||||
|
'!exclude' NOT IN (
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
|
WHERE ttj.transaction_id = transaction.id
|
||||||
|
) AND
|
||||||
|
(
|
||||||
|
SELECT COUNT(tli.id) > 0
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
WHERE tli.transaction_id = transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY c_id""",
|
||||||
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
|
this::parseAmountAndCategory
|
||||||
|
);
|
||||||
|
return combineCategorizedAmounts(List.of(
|
||||||
|
totalsBeforeLineItems,
|
||||||
|
totalsFromLineItemsOnly,
|
||||||
|
totalsFromLeftoverTransactions
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Pair<TransactionCategory, BigDecimal>> groupByRootCategory(List<Pair<TransactionCategory, BigDecimal>> spendByCategory) {
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> result = new ArrayList<>();
|
||||||
|
Map<TransactionCategory, BigDecimal> rootCategorySpend = new HashMap<>();
|
||||||
|
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
|
||||||
|
BigDecimal uncategorizedSpend = BigDecimal.ZERO;
|
||||||
|
for (var spend : spendByCategory) {
|
||||||
|
if (spend.first() == null) {
|
||||||
|
uncategorizedSpend = uncategorizedSpend.add(spend.second());
|
||||||
|
} else {
|
||||||
|
TransactionCategory rootCategory = categoryRepo.findRoot(spend.first().id);
|
||||||
|
if (rootCategory != null) {
|
||||||
|
BigDecimal categoryTotal = rootCategorySpend.getOrDefault(rootCategory, BigDecimal.ZERO);
|
||||||
|
rootCategorySpend.put(rootCategory, categoryTotal.add(spend.second()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var entry : rootCategorySpend.entrySet()) {
|
||||||
|
result.add(new Pair<>(entry.getKey(), entry.getValue()));
|
||||||
|
}
|
||||||
|
if (uncategorizedSpend.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
result.add(new Pair<>(null, uncategorizedSpend));
|
||||||
|
}
|
||||||
|
result.sort((p1, p2) -> p2.second().compareTo(p1.second()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pair<TransactionCategory, BigDecimal> parseAmountAndCategory(ResultSet rs) throws SQLException {
|
||||||
|
BigDecimal amount = rs.getBigDecimal(1);
|
||||||
|
long categoryId = rs.getLong(2);
|
||||||
|
if (rs.wasNull()) {
|
||||||
|
return new Pair<>(null, amount);
|
||||||
|
}
|
||||||
|
Long parentId = rs.getLong(3);
|
||||||
|
if (rs.wasNull()) parentId = null;
|
||||||
|
String name = rs.getString(4);
|
||||||
|
Color color = Color.valueOf("#" + rs.getString(5));
|
||||||
|
return new Pair<>(new TransactionCategory(categoryId, parentId, name, color), amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Pair<TransactionCategory, BigDecimal>> combineCategorizedAmounts(List<List<Pair<TransactionCategory, BigDecimal>>> lists) {
|
||||||
|
BigDecimal uncategorizedAmount = BigDecimal.ZERO;
|
||||||
|
Map<TransactionCategory, BigDecimal> categorizedAmounts = new HashMap<>();
|
||||||
|
for (var list : lists) {
|
||||||
|
for (var p : list) {
|
||||||
|
if (p.first() == null) {
|
||||||
|
uncategorizedAmount = uncategorizedAmount.add(p.second());
|
||||||
|
} else {
|
||||||
|
BigDecimal value = categorizedAmounts.computeIfAbsent(p.first(), category -> BigDecimal.ZERO);
|
||||||
|
categorizedAmounts.put(p.first(), value.add(p.second()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> amountsByCategory = new ArrayList<>();
|
||||||
|
amountsByCategory.add(new Pair<>(null, uncategorizedAmount));
|
||||||
|
for (var entry : categorizedAmounts.entrySet()) {
|
||||||
|
amountsByCategory.add(new Pair<>(entry.getKey(), entry.getValue()));
|
||||||
|
}
|
||||||
|
amountsByCategory.sort((p1, p2) -> p2.second().compareTo(p1.second()));
|
||||||
|
return amountsByCategory;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -19,12 +19,12 @@ import java.util.Optional;
|
||||||
|
|
||||||
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
|
public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
long recordId = DbUtil.insertOne(
|
long recordId = DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
"INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)",
|
||||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode())
|
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode())
|
||||||
);
|
);
|
||||||
// Insert attachments.
|
// Insert attachments.
|
||||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
|
@ -35,39 +35,46 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add a history item entry.
|
|
||||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
|
||||||
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
|
|
||||||
return recordId;
|
return recordId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BalanceRecord findLatestByAccountId(long accountId) {
|
public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1",
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1",
|
||||||
List.of(accountId),
|
List.of(accountId, type.name()),
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
).orElse(null);
|
).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
public Optional<BalanceRecord> findById(long id) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findById(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
"SELECT * FROM balance_record WHERE id = ?",
|
||||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
id,
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) {
|
public Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||||
|
JdbcBalanceRecordRepository::parse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||||
|
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -102,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||||
rs.getLong("account_id"),
|
rs.getLong("account_id"),
|
||||||
|
BalanceRecordType.valueOf(rs.getString("type").toUpperCase()),
|
||||||
rs.getBigDecimal("balance"),
|
rs.getBigDecimal("balance"),
|
||||||
Currency.getInstance(rs.getString("currency"))
|
Currency.getInstance(rs.getString("currency"))
|
||||||
);
|
);
|
||||||
|
|
|
@ -49,13 +49,38 @@ public class JdbcDataSource implements DataSource {
|
||||||
return new JdbcTransactionRepository(getConnection(), contentDir);
|
return new JdbcTransactionRepository(getConnection(), contentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionVendorRepository getTransactionVendorRepository() {
|
||||||
|
return new JdbcTransactionVendorRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionCategoryRepository getTransactionCategoryRepository() {
|
||||||
|
return new JdbcTransactionCategoryRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionLineItemRepository getTransactionLineItemRepository() {
|
||||||
|
return new JdbcTransactionLineItemRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AttachmentRepository getAttachmentRepository() {
|
public AttachmentRepository getAttachmentRepository() {
|
||||||
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AccountHistoryItemRepository getAccountHistoryItemRepository() {
|
public HistoryRepository getHistoryRepository() {
|
||||||
return new JdbcAccountHistoryItemRepository(getConnection());
|
return new JdbcHistoryRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SavedQueryRepository getSavedQueryRepository() {
|
||||||
|
return new FileSystemSavedQueryRepository(contentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AnalyticsRepository getAnalyticsRepository() {
|
||||||
|
return new JdbcAnalyticsRepository(getConnection());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.DataSource;
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.DataSourceFactory;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
import com.andrewlalis.perfin.data.impl.migration.Migration;
|
import com.andrewlalis.perfin.data.impl.migration.Migration;
|
||||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -14,16 +19,14 @@ import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.*;
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that's responsible for obtaining a JDBC data source for a profile.
|
* Component that's responsible for obtaining a JDBC data source for a profile.
|
||||||
*/
|
*/
|
||||||
public class JdbcDataSourceFactory {
|
public class JdbcDataSourceFactory implements DataSourceFactory {
|
||||||
private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
|
private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -31,8 +34,11 @@ public class JdbcDataSourceFactory {
|
||||||
* loaded with an old schema version, then we'll migrate to the latest. If
|
* loaded with an old schema version, then we'll migrate to the latest. If
|
||||||
* the profile has a newer schema version, we'll exit and prompt the user
|
* the profile has a newer schema version, we'll exit and prompt the user
|
||||||
* to update their app.
|
* to update their app.
|
||||||
|
* <p>
|
||||||
|
* This value should be one higher than the
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
public static final int SCHEMA_VERSION = 1;
|
public static final int SCHEMA_VERSION = 6;
|
||||||
|
|
||||||
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
||||||
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
||||||
|
@ -56,7 +62,18 @@ public class JdbcDataSourceFactory {
|
||||||
throw new ProfileLoadException("Profile " + profileName + " has a database with an unsupported schema version.");
|
throw new ProfileLoadException("Profile " + profileName + " has a database with an unsupported schema version.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
|
var dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
|
||||||
|
if (!testConnection(dataSource)) {
|
||||||
|
throw new ProfileLoadException("Unabled to connect to the profile's database.");
|
||||||
|
}
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SchemaStatus getSchemaStatus(String profileName) throws IOException {
|
||||||
|
int existingSchemaVersion = getSchemaVersion(profileName);
|
||||||
|
if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
|
||||||
|
if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
|
||||||
|
return SchemaStatus.INCOMPATIBLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createNewDatabase(String profileName) throws ProfileLoadException {
|
private void createNewDatabase(String profileName) throws ProfileLoadException {
|
||||||
|
@ -69,6 +86,7 @@ public class JdbcDataSourceFactory {
|
||||||
if (in == null) throw new IOException("Could not load database schema SQL file.");
|
if (in == null) throw new IOException("Could not load database schema SQL file.");
|
||||||
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
executeSqlScript(schemaStr, conn);
|
executeSqlScript(schemaStr, conn);
|
||||||
|
insertDefaultData(conn);
|
||||||
try {
|
try {
|
||||||
writeCurrentSchemaVersion(profileName);
|
writeCurrentSchemaVersion(profileName);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
@ -89,6 +107,66 @@ public class JdbcDataSourceFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts all default data into the database, using static content found in
|
||||||
|
* various locations on the classpath.
|
||||||
|
* @param conn The connection to use to insert data.
|
||||||
|
* @throws IOException If resources couldn't be read.
|
||||||
|
* @throws SQLException If SQL fails.
|
||||||
|
*/
|
||||||
|
public void insertDefaultData(Connection conn) throws IOException, SQLException {
|
||||||
|
insertDefaultCategories(conn);
|
||||||
|
insertDefaultTags(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
|
||||||
|
try (
|
||||||
|
var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
|
||||||
|
var stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
|
||||||
|
Statement.RETURN_GENERATED_KEYS
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
|
||||||
|
insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
|
||||||
|
for (JsonNode obj : categoriesArray) {
|
||||||
|
String name = obj.get("name").asText();
|
||||||
|
String colorHex = parentColorHex;
|
||||||
|
if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
|
||||||
|
if (parentId == null) {
|
||||||
|
stmt.setNull(1, Types.BIGINT);
|
||||||
|
} else {
|
||||||
|
stmt.setLong(1, parentId);
|
||||||
|
}
|
||||||
|
stmt.setString(2, name);
|
||||||
|
stmt.setString(3, colorHex.substring(1));
|
||||||
|
int result = stmt.executeUpdate();
|
||||||
|
if (result != 1) throw new SQLException("Failed to insert category.");
|
||||||
|
long id = DbUtil.getGeneratedId(stmt);
|
||||||
|
if (obj.hasNonNull("children") && obj.get("children").isArray()) {
|
||||||
|
insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDefaultTags(Connection conn) throws SQLException {
|
||||||
|
final List<String> defaultTags = List.of(
|
||||||
|
"!exclude"
|
||||||
|
);
|
||||||
|
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag (name) VALUES (?)")) {
|
||||||
|
for (var tag : defaultTags) {
|
||||||
|
stmt.setString(1, tag);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean testConnection(JdbcDataSource dataSource) {
|
private boolean testConnection(JdbcDataSource dataSource) {
|
||||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||||
return stmt.execute("SELECT 1;");
|
return stmt.execute("SELECT 1;");
|
||||||
|
@ -168,7 +246,7 @@ public class JdbcDataSourceFactory {
|
||||||
return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
|
return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int getSchemaVersion(String profileName) throws IOException {
|
public int getSchemaVersion(String profileName) throws IOException {
|
||||||
if (Files.exists(getSchemaVersionFile(profileName))) {
|
if (Files.exists(getSchemaVersionFile(profileName))) {
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());
|
return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
|
||||||
|
@Override
|
||||||
|
public long getOrCreateHistoryForAccount(long accountId) {
|
||||||
|
return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getOrCreateHistoryForTransaction(long transactionId) {
|
||||||
|
return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
|
||||||
|
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
|
||||||
|
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
|
||||||
|
if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
|
||||||
|
long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
|
||||||
|
String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
|
||||||
|
DbUtil.updateOne(conn, insertQuery, entityId, historyId);
|
||||||
|
return historyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteHistoryForAccount(long accountId) {
|
||||||
|
deleteHistoryForEntity(accountId, "history_account", "account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteHistoryForTransaction(long transactionId) {
|
||||||
|
deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
|
||||||
|
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
|
||||||
|
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
|
||||||
|
if (optionalHistoryId.isPresent()) {
|
||||||
|
long historyId = optionalHistoryId.get();
|
||||||
|
DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
|
||||||
|
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.Type.TEXT.name());
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
|
||||||
|
itemId,
|
||||||
|
description
|
||||||
|
);
|
||||||
|
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<HistoryItem> getItem(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM history_item WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcHistoryRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
|
||||||
|
historyId,
|
||||||
|
DbUtil.timestampFromUtcLDT(timestamp),
|
||||||
|
type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM history_item WHERE history_id = ?",
|
||||||
|
pagination,
|
||||||
|
List.of(historyId),
|
||||||
|
JdbcHistoryRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM history_item
|
||||||
|
WHERE history_id = ? AND timestamp <= ?
|
||||||
|
ORDER BY timestamp DESC""",
|
||||||
|
List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
|
||||||
|
JdbcHistoryRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HistoryItem parseItem(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong(1);
|
||||||
|
long historyId = rs.getLong(2);
|
||||||
|
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
|
||||||
|
String type = rs.getString(4);
|
||||||
|
if (type.equalsIgnoreCase(HistoryItem.Type.TEXT.name())) {
|
||||||
|
String description = DbUtil.findOne(
|
||||||
|
rs.getStatement().getConnection(),
|
||||||
|
"SELECT description FROM history_item_text WHERE id = ?",
|
||||||
|
List.of(id),
|
||||||
|
r -> r.getString(1)
|
||||||
|
).orElseThrow();
|
||||||
|
return new HistoryTextItem(id, historyId, timestamp, description);
|
||||||
|
}
|
||||||
|
throw new SQLException("Unknown history item type: " + type);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,154 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.ColorUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionCategory> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionCategory> findByName(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionCategory> findAllBaseCategories() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionCategory> findAll() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionCategory findRoot(long categoryId) {
|
||||||
|
TransactionCategory category = findById(categoryId).orElse(null);
|
||||||
|
if (category == null || category.getParentId() == null) return category;
|
||||||
|
return findRoot(category.getParentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(long parentId, String name, Color color) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
|
||||||
|
List.of(parentId, name, ColorUtil.toHex(color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name, Color color) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_category (name, color) VALUES (?, ?)",
|
||||||
|
List.of(name, ColorUtil.toHex(color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(long id, String name, Color color) {
|
||||||
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
TransactionCategory category = findById(id).orElseThrow();
|
||||||
|
if (!category.getName().equals(name)) {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"UPDATE transaction_category SET name = ? WHERE id = ?",
|
||||||
|
name,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!category.getColor().equals(color)) {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"UPDATE transaction_category SET color = ? WHERE id = ?",
|
||||||
|
ColorUtil.toHex(color),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(long id) {
|
||||||
|
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CategoryTreeNode> findTree() {
|
||||||
|
List<TransactionCategory> rootCategories = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
|
||||||
|
for (var category : rootCategories) {
|
||||||
|
rootNodes.add(findTreeRecursive(category));
|
||||||
|
}
|
||||||
|
return rootNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CategoryTreeNode findTree(TransactionCategory root) {
|
||||||
|
return findTreeRecursive(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
|
||||||
|
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
|
||||||
|
List<TransactionCategory> childCategories = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
|
||||||
|
List.of(root.id),
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
for (var childCategory : childCategories) {
|
||||||
|
node.children().add(findTreeRecursive(childCategory));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
Long parentId = rs.getLong("parent_id");
|
||||||
|
if (rs.wasNull()) parentId = null;
|
||||||
|
String name = rs.getString("name");
|
||||||
|
Color color = Color.valueOf("#" + rs.getString("color"));
|
||||||
|
return new TransactionCategory(id, parentId, name, color);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionLineItemRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record JdbcTransactionLineItemRepository(Connection conn) implements TransactionLineItemRepository {
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> findItems(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_line_item WHERE transaction_id = ? ORDER BY idx ASC",
|
||||||
|
List.of(transactionId),
|
||||||
|
JdbcTransactionLineItemRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items) {
|
||||||
|
// First delete all existing line items since it's just easier that way.
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_line_item WHERE transaction_id = ?", transactionId);
|
||||||
|
if (items.isEmpty()) return Collections.emptyList(); // Skip insertion logic if no items are present.
|
||||||
|
String query = """
|
||||||
|
INSERT INTO transaction_line_item (
|
||||||
|
transaction_id,
|
||||||
|
value_per_item,
|
||||||
|
quantity,
|
||||||
|
idx,
|
||||||
|
description,
|
||||||
|
category_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)""";
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
TransactionLineItem item = items.get(i);
|
||||||
|
stmt.setLong(1, transactionId);
|
||||||
|
stmt.setBigDecimal(2, item.getValuePerItem());
|
||||||
|
stmt.setInt(3, item.getQuantity());
|
||||||
|
stmt.setInt(4, i);
|
||||||
|
stmt.setString(5, item.getDescription());
|
||||||
|
if (item.getCategoryId() == null) {
|
||||||
|
stmt.setNull(6, Types.BIGINT);
|
||||||
|
} else {
|
||||||
|
stmt.setLong(6, item.getCategoryId());
|
||||||
|
}
|
||||||
|
int rowCount = stmt.executeUpdate();
|
||||||
|
if (rowCount != 1) throw new SQLException("Failed to insert line item.");
|
||||||
|
}
|
||||||
|
return findItems(transactionId); // Simply re-fetch items afterward. Their properties may have changed.
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionLineItem parseItem(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
long transactionId = rs.getLong("transaction_id");
|
||||||
|
BigDecimal valuePerItem = rs.getBigDecimal("value_per_item");
|
||||||
|
int quantity = rs.getInt("quantity");
|
||||||
|
int idx = rs.getInt("idx");
|
||||||
|
String description = rs.getString("description");
|
||||||
|
Long categoryId = rs.getLong("category_id");
|
||||||
|
if (rs.wasNull()) categoryId = null;
|
||||||
|
return new TransactionLineItem(id, transactionId, valuePerItem, quantity, idx, description, categoryId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,19 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.*;
|
||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
|
||||||
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.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
|
||||||
import com.andrewlalis.perfin.model.*;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.*;
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
@ -28,29 +26,109 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
) {
|
) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
// 1. Insert the transaction.
|
Long vendorId = null;
|
||||||
long txId = DbUtil.insertOne(
|
if (vendor != null && !vendor.isBlank()) {
|
||||||
conn,
|
vendorId = getOrCreateVendorId(vendor.strip());
|
||||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
}
|
||||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
|
Long categoryId = null;
|
||||||
);
|
if (category != null && !category.isBlank()) {
|
||||||
// 2. Insert linked account entries.
|
categoryId = getOrCreateCategoryId(category.strip());
|
||||||
|
}
|
||||||
|
// Insert the transaction, using a custom JDBC statement to deal with nullables.
|
||||||
|
long txId;
|
||||||
|
try (var stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
Statement.RETURN_GENERATED_KEYS
|
||||||
|
)) {
|
||||||
|
stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||||
|
stmt.setBigDecimal(2, amount);
|
||||||
|
stmt.setString(3, currency.getCurrencyCode());
|
||||||
|
if (description != null && !description.isBlank()) {
|
||||||
|
stmt.setString(4, description.strip());
|
||||||
|
} else {
|
||||||
|
stmt.setNull(4, Types.VARCHAR);
|
||||||
|
}
|
||||||
|
if (vendorId != null) {
|
||||||
|
stmt.setLong(5, vendorId);
|
||||||
|
} else {
|
||||||
|
stmt.setNull(5, Types.BIGINT);
|
||||||
|
}
|
||||||
|
if (categoryId != null) {
|
||||||
|
stmt.setLong(6, categoryId);
|
||||||
|
} else {
|
||||||
|
stmt.setNull(6, Types.BIGINT);
|
||||||
|
}
|
||||||
|
int result = stmt.executeUpdate();
|
||||||
|
if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
|
||||||
|
var rs = stmt.getGeneratedKeys();
|
||||||
|
if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
|
||||||
|
txId = rs.getLong(1);
|
||||||
|
}
|
||||||
|
// Insert linked account entries.
|
||||||
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
||||||
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
|
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
|
||||||
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
|
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
|
||||||
// 3. Add attachments.
|
// Add attachments.
|
||||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
for (Path attachmentPath : attachments) {
|
for (Path attachmentPath : attachments) {
|
||||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||||
insertAttachmentLink(txId, attachment.id);
|
insertAttachmentLink(txId, attachment.id);
|
||||||
}
|
}
|
||||||
|
// Add tags.
|
||||||
|
for (String tag : tags) {
|
||||||
|
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
|
||||||
|
long tagId = getOrCreateTagId(tag.toLowerCase().strip());
|
||||||
|
stmt.setLong(1, txId);
|
||||||
|
stmt.setLong(2, tagId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
// Add Line Items.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
lineItemRepo.saveItems(txId, lineItems);
|
||||||
|
|
||||||
return txId;
|
return txId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getOrCreateVendorId(String name) {
|
||||||
|
var repo = new JdbcTransactionVendorRepository(conn);
|
||||||
|
TransactionVendor vendor = repo.findByName(name).orElse(null);
|
||||||
|
if (vendor != null) {
|
||||||
|
return vendor.id;
|
||||||
|
}
|
||||||
|
return repo.insert(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getOrCreateCategoryId(String name) {
|
||||||
|
var repo = new JdbcTransactionCategoryRepository(conn);
|
||||||
|
TransactionCategory category = repo.findByName(name).orElse(null);
|
||||||
|
if (category != null) {
|
||||||
|
return category.id;
|
||||||
|
}
|
||||||
|
return repo.insert(name, Color.WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getOrCreateTagId(String name) {
|
||||||
|
Optional<Long> optionalId = DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM transaction_tag WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
rs -> rs.getLong(1)
|
||||||
|
);
|
||||||
|
return optionalId.orElseGet(() ->
|
||||||
|
DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Transaction> findById(long id) {
|
public Optional<Transaction> findById(long id) {
|
||||||
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
|
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
|
||||||
|
@ -66,6 +144,25 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Transaction> findRecentN(int n) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT " + n,
|
||||||
|
JdbcTransactionRepository::parseTransaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction WHERE timestamp = ? AND amount = ? AND currency = ? ORDER BY timestamp DESC",
|
||||||
|
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode()),
|
||||||
|
JdbcTransactionRepository::parseTransaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countAll() {
|
public long countAll() {
|
||||||
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
|
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
|
||||||
|
@ -105,6 +202,26 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Transaction> findEarliest() {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1",
|
||||||
|
Collections.emptyList(),
|
||||||
|
JdbcTransactionRepository::parseTransaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Transaction> findLatest() {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1",
|
||||||
|
Collections.emptyList(),
|
||||||
|
JdbcTransactionRepository::parseTransaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
||||||
Account creditAccount = DbUtil.findOne(
|
Account creditAccount = DbUtil.findOne(
|
||||||
|
@ -147,6 +264,51 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> findTags(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
|
||||||
|
WHERE ttj.transaction_id = ?
|
||||||
|
ORDER BY tt.name ASC""",
|
||||||
|
List.of(transactionId),
|
||||||
|
rs -> rs.getString(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> findAllTags() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT name FROM transaction_tag ORDER BY name ASC",
|
||||||
|
rs -> rs.getString(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteTag(String name) {
|
||||||
|
DbUtil.update(
|
||||||
|
conn,
|
||||||
|
"DELETE FROM transaction_tag WHERE name = ?",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countTagUsages(String name) {
|
||||||
|
return DbUtil.count(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT COUNT(transaction_id)
|
||||||
|
FROM transaction_tag_join
|
||||||
|
WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(long transactionId) {
|
public void delete(long transactionId) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
@ -164,44 +326,94 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
) {
|
) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
Transaction tx = findById(id).orElseThrow();
|
|
||||||
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
|
|
||||||
List<Attachment> currentAttachments = findAttachments(id);
|
|
||||||
var entryRepo = new JdbcAccountEntryRepository(conn);
|
var entryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
|
var vendorRepo = new JdbcTransactionVendorRepository(conn);
|
||||||
|
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
|
||||||
|
|
||||||
|
Transaction tx = findById(id).orElseThrow();
|
||||||
|
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
|
||||||
|
TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
|
||||||
|
String currentVendorName = currentVendor == null ? null : currentVendor.getName();
|
||||||
|
TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
|
||||||
|
String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
|
||||||
|
Set<String> currentTags = new HashSet<>(findTags(id));
|
||||||
|
List<Attachment> currentAttachments = findAttachments(id);
|
||||||
|
|
||||||
List<String> updateMessages = new ArrayList<>();
|
List<String> updateMessages = new ArrayList<>();
|
||||||
if (!tx.getTimestamp().equals(utcTimestamp)) {
|
if (!tx.getTimestamp().equals(utcTimestamp)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
|
||||||
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
|
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
|
||||||
}
|
}
|
||||||
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
|
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
|
||||||
if (!tx.getAmount().equals(scaledAmount)) {
|
if (!tx.getAmount().equals(scaledAmount)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
|
||||||
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
|
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
|
||||||
}
|
}
|
||||||
if (!tx.getCurrency().equals(currency)) {
|
if (!tx.getCurrency().equals(currency)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
|
||||||
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
|
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(tx.getDescription(), description)) {
|
if (!Objects.equals(tx.getDescription(), description)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
|
||||||
updateMessages.add("Updated description.");
|
updateMessages.add("Updated description.");
|
||||||
}
|
}
|
||||||
boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
|
boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
|
||||||
!tx.getCurrency().equals(currency) ||
|
!tx.getCurrency().equals(currency) ||
|
||||||
!tx.getTimestamp().equals(utcTimestamp) ||
|
!tx.getTimestamp().equals(utcTimestamp) ||
|
||||||
!currentLinkedAccounts.equals(linkedAccounts);
|
!currentLinkedAccounts.equals(linkedAccounts);
|
||||||
if (updateAccountEntries) {
|
if (shouldUpdateAccountEntries) {
|
||||||
// Delete all entries and re-write them correctly?
|
// Delete all entries and re-write them correctly.
|
||||||
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id));
|
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
|
||||||
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
|
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
|
||||||
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
|
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
|
||||||
updateMessages.add("Updated linked accounts.");
|
updateMessages.add("Updated linked accounts.");
|
||||||
}
|
}
|
||||||
|
// Manage vendor change.
|
||||||
|
if (!Objects.equals(vendor, currentVendorName)) {
|
||||||
|
if (vendor == null || vendor.isBlank()) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
|
||||||
|
} else {
|
||||||
|
long newVendorId = getOrCreateVendorId(vendor);
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
|
||||||
|
}
|
||||||
|
updateMessages.add("Updated vendor name to \"" + vendor + "\".");
|
||||||
|
}
|
||||||
|
// Manage category change.
|
||||||
|
if (!Objects.equals(category, currentCategoryName)) {
|
||||||
|
if (category == null || category.isBlank()) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
|
||||||
|
} else {
|
||||||
|
long newCategoryId = getOrCreateCategoryId(category);
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
|
||||||
|
}
|
||||||
|
updateMessages.add("Updated category name to \"" + category + "\".");
|
||||||
|
}
|
||||||
|
// Manage tags changes.
|
||||||
|
if (!currentTags.equals(tags)) {
|
||||||
|
Set<String> tagsAdded = new HashSet<>(tags);
|
||||||
|
tagsAdded.removeAll(currentTags);
|
||||||
|
Set<String> tagsRemoved = new HashSet<>(currentTags);
|
||||||
|
tagsRemoved.removeAll(tags);
|
||||||
|
|
||||||
|
for (var t : tagsRemoved) removeTag(id, t);
|
||||||
|
for (var t : tagsAdded) addTag(id, t);
|
||||||
|
|
||||||
|
if (!tagsAdded.isEmpty()) {
|
||||||
|
updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
|
||||||
|
}
|
||||||
|
if (!tagsRemoved.isEmpty()) {
|
||||||
|
updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Manage attachments changes.
|
// Manage attachments changes.
|
||||||
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
|
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
|
||||||
removedAttachments.removeAll(existingAttachments);
|
removedAttachments.removeAll(existingAttachments);
|
||||||
|
@ -214,10 +426,19 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
insertAttachmentLink(tx.id, attachment.id);
|
insertAttachmentLink(tx.id, attachment.id);
|
||||||
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
||||||
}
|
}
|
||||||
|
// Manage line item changes.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
List<TransactionLineItem> existingLineItems = lineItemRepo.findItems(tx.id);
|
||||||
|
if (!existingLineItems.equals(lineItems)) {
|
||||||
|
lineItemRepo.saveItems(tx.id, lineItems);
|
||||||
|
updateMessages.add("Updated line items.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a text history item to any linked accounts detailing the changes.
|
||||||
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
||||||
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||||
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
|
long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
|
||||||
linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
|
historyRepo.addTextItem(historyId, updateMessageStr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -226,16 +447,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
conn.close();
|
conn.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
|
||||||
return new Transaction(
|
|
||||||
rs.getLong("id"),
|
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
|
||||||
rs.getBigDecimal("amount"),
|
|
||||||
Currency.getInstance(rs.getString("currency")),
|
|
||||||
rs.getString("description")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertAttachmentLink(long transactionId, long attachmentId) {
|
private void insertAttachmentLink(long transactionId, long attachmentId) {
|
||||||
DbUtil.insertOne(
|
DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
|
@ -243,4 +454,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
List.of(transactionId, attachmentId)
|
List.of(transactionId, attachmentId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getTagId(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM transaction_tag WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
rs -> rs.getLong(1)
|
||||||
|
).orElse(-1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeTag(long transactionId, String tag) {
|
||||||
|
long id = getTagId(tag);
|
||||||
|
if (id != -1) {
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTag(long transactionId, String tag) {
|
||||||
|
long id = getOrCreateTagId(tag);
|
||||||
|
boolean exists = DbUtil.count(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
|
||||||
|
transactionId,
|
||||||
|
id
|
||||||
|
) > 0;
|
||||||
|
if (!exists) {
|
||||||
|
DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
|
||||||
|
transactionId,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
||||||
|
return new Transaction(
|
||||||
|
rs.getLong("id"),
|
||||||
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||||
|
rs.getBigDecimal("amount"),
|
||||||
|
Currency.getInstance(rs.getString("currency")),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getObject("vendor_id", Long.class),
|
||||||
|
rs.getObject("category_id", Long.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionVendor> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionVendor> findByName(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionVendor> findAll() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor ORDER BY name ASC",
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name, String description) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
|
||||||
|
List.of(name, description)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_vendor (name) VALUES (?)",
|
||||||
|
List.of(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(long id, String name, String description) {
|
||||||
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
TransactionVendor vendor = findById(id).orElseThrow();
|
||||||
|
if (!vendor.getName().equals(name)) {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"UPDATE transaction_vendor SET name = ? WHERE id = ?",
|
||||||
|
name,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Objects.equals(vendor.getDescription(), description)) {
|
||||||
|
DbUtil.updateOne(
|
||||||
|
conn,
|
||||||
|
"UPDATE transaction_vendor SET description = ? WHERE id = ?",
|
||||||
|
description,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(long id) {
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
|
||||||
|
return new TransactionVendor(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("description")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,10 +4,23 @@ import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for defining and using all known migrations.
|
||||||
|
*/
|
||||||
public class Migrations {
|
public class Migrations {
|
||||||
|
/**
|
||||||
|
* Gets a list of migrations, as a map with the key being the version to
|
||||||
|
* migrate from. For example, a migration that takes us from version 42 to
|
||||||
|
* 43 would exist in the map with key 42.
|
||||||
|
* @return The map of all migrations.
|
||||||
|
*/
|
||||||
public static Map<Integer, Migration> getMigrations() {
|
public static Map<Integer, Migration> getMigrations() {
|
||||||
final Map<Integer, Migration> migrations = new HashMap<>();
|
final Map<Integer, Migration> migrations = new HashMap<>();
|
||||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
|
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||||
|
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
||||||
|
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
|
||||||
|
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
|
||||||
|
migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
|
||||||
return migrations;
|
return migrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,4 +38,14 @@ public class Migrations {
|
||||||
}
|
}
|
||||||
return selectedMigration;
|
return selectedMigration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Map<Integer, String> getSchemaVersionCompatibility() {
|
||||||
|
final Map<Integer, String> compatibilities = new HashMap<>();
|
||||||
|
compatibilities.put(1, "1.4.0");
|
||||||
|
return compatibilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getLatestCompatibleVersion(int schemaVersion) {
|
||||||
|
return getSchemaVersionCompatibility().get(schemaVersion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.andrewlalis.perfin.data.search;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An entity searcher will search for entities matching a list of filters.
|
||||||
|
* @param <T> The entity type to search over.
|
||||||
|
*/
|
||||||
|
public interface EntitySearcher<T> {
|
||||||
|
/**
|
||||||
|
* Gets a page of results that match the given filters.
|
||||||
|
* @param pageRequest The page request.
|
||||||
|
* @param filters The filters to apply.
|
||||||
|
* @return A page of results.
|
||||||
|
*/
|
||||||
|
Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of results that would be returned for a given set of
|
||||||
|
* filters.
|
||||||
|
* @param filters The filters to apply.
|
||||||
|
* @return The number of entities that match.
|
||||||
|
*/
|
||||||
|
long resultCount(List<SearchFilter> filters);
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
package com.andrewlalis.perfin.data.search;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
|
import com.andrewlalis.perfin.data.util.ResultSetMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
|
||||||
|
|
||||||
|
private final Connection conn;
|
||||||
|
private final String countExpression;
|
||||||
|
private final String selectExpression;
|
||||||
|
private final ResultSetMapper<T> resultSetMapper;
|
||||||
|
|
||||||
|
public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
|
||||||
|
this.conn = conn;
|
||||||
|
this.countExpression = countExpression;
|
||||||
|
this.selectExpression = selectExpression;
|
||||||
|
this.resultSetMapper = resultSetMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
|
||||||
|
if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
List<Pair<Integer, Object>> args = new ArrayList<>();
|
||||||
|
for (var filter : filters) {
|
||||||
|
args.addAll(filter.args());
|
||||||
|
for (var joinClause : filter.joinClauses()) {
|
||||||
|
sb.append(joinClause).append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("WHERE\n");
|
||||||
|
for (int i = 0; i < filters.size(); i++) {
|
||||||
|
sb.append(filters.get(i).whereClause());
|
||||||
|
if (i < filters.size() - 1) {
|
||||||
|
sb.append(" AND");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
return new Pair<>(sb.toString(), args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
|
||||||
|
for (int i = 1; i <= args.size(); i++) {
|
||||||
|
Pair<Integer, Object> arg = args.get(i - 1);
|
||||||
|
if (arg.second() == null) {
|
||||||
|
stmt.setNull(i, arg.first());
|
||||||
|
} else {
|
||||||
|
stmt.setObject(i, arg.second(), arg.first());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
|
||||||
|
var baseQueryAndArgs = buildSearchQuery(filters);
|
||||||
|
StringBuilder sqlBuilder = new StringBuilder(selectExpression);
|
||||||
|
if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
|
||||||
|
sqlBuilder.append('\n').append(baseQueryAndArgs.first());
|
||||||
|
}
|
||||||
|
String pagingSql = pageRequest.toSQL();
|
||||||
|
if (pagingSql != null && !pagingSql.isBlank()) {
|
||||||
|
sqlBuilder.append('\n').append(pagingSql);
|
||||||
|
}
|
||||||
|
String sql = sqlBuilder.toString();
|
||||||
|
logger.debug(
|
||||||
|
"Searching with query:\n{}\nWith arguments: {}",
|
||||||
|
sql,
|
||||||
|
baseQueryAndArgs.second().stream()
|
||||||
|
.map(Pair::second)
|
||||||
|
.map(Object::toString)
|
||||||
|
.collect(Collectors.joining(", "))
|
||||||
|
);
|
||||||
|
try (var stmt = conn.prepareStatement(sql)) {
|
||||||
|
applyArgs(stmt, baseQueryAndArgs.second());
|
||||||
|
ResultSet rs = stmt.executeQuery();
|
||||||
|
List<T> results = new ArrayList<>(pageRequest.size());
|
||||||
|
while (rs.next() && results.size() < pageRequest.size()) {
|
||||||
|
results.add(resultSetMapper.map(rs));
|
||||||
|
}
|
||||||
|
return new Page<>(results, pageRequest);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.error("Search failed.", e);
|
||||||
|
return new Page<>(Collections.emptyList(), pageRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long resultCount(List<SearchFilter> filters) {
|
||||||
|
var baseQueryAndArgs = buildSearchQuery(filters);
|
||||||
|
String sql = countExpression + "\n" + baseQueryAndArgs.first();
|
||||||
|
try (var stmt = conn.prepareStatement(sql)) {
|
||||||
|
applyArgs(stmt, baseQueryAndArgs.second());
|
||||||
|
ResultSet rs = stmt.executeQuery();
|
||||||
|
if (!rs.next()) throw new SQLException("No count result.");
|
||||||
|
return rs.getLong(1);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.error("Failed to get search result count.", e);
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
package com.andrewlalis.perfin.data.search;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
|
||||||
|
public JdbcTransactionSearcher(Connection conn) {
|
||||||
|
super(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(transaction.id) FROM transaction",
|
||||||
|
"SELECT transaction.* FROM transaction",
|
||||||
|
JdbcTransactionSearcher::parseResultSet
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transaction parseResultSet(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong(1);
|
||||||
|
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
|
||||||
|
BigDecimal amount = rs.getBigDecimal(3);
|
||||||
|
Currency currency = Currency.getInstance(rs.getString(4));
|
||||||
|
String description = rs.getString(5);
|
||||||
|
Long vendorId = rs.getLong(6);
|
||||||
|
if (rs.wasNull()) vendorId = null;
|
||||||
|
Long categoryId = rs.getLong(7);
|
||||||
|
if (rs.wasNull()) categoryId = null;
|
||||||
|
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FilterBuilder {
|
||||||
|
private final List<SearchFilter> filters = new ArrayList<>();
|
||||||
|
private final Set<String> joinTables = new HashSet<>();
|
||||||
|
|
||||||
|
public List<SearchFilter> build() {
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAccounts(Collection<Account> accounts, boolean exclude) {
|
||||||
|
if (accounts.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
String idsString = accounts.stream()
|
||||||
|
.map(a -> Long.toString(a.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "account_entry.account_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAccountTypes(Collection<AccountType> types, boolean exclude) {
|
||||||
|
if (types.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountJoin(builder);
|
||||||
|
String typesString = types.stream()
|
||||||
|
.map(t -> "'" + t.name() + "'").distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "account.account_type", typesString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byCategories(Collection<TransactionCategory> categories, boolean exclude) {
|
||||||
|
if (categories.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
Set<Long> ids = Profile.getCurrent().dataSource().mapRepo(TransactionCategoryRepository.class, repo -> {
|
||||||
|
Set<Long> categoryIds = new HashSet<>();
|
||||||
|
for (var category : categories) {
|
||||||
|
var treeNode = repo.findTree(category);
|
||||||
|
categoryIds.addAll(treeNode.allIds());
|
||||||
|
}
|
||||||
|
return categoryIds;
|
||||||
|
});
|
||||||
|
String idsString = ids.stream()
|
||||||
|
.map(id -> Long.toString(id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.category_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byVendors(Collection<TransactionVendor> vendors, boolean exclude) {
|
||||||
|
if (vendors.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String idsString = vendors.stream()
|
||||||
|
.map(v -> Long.toString(v.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.vendor_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byTags(Collection<TransactionTag> tags, boolean exclude) {
|
||||||
|
if (tags.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addTagJoin(builder);
|
||||||
|
var tagIdsString = tags.stream()
|
||||||
|
.map(t -> Long.toString(t.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction_tag_join.tag_id", tagIdsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountGreaterThan(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount > ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountLessThan(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount < ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountEqualTo(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount = ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byEntryType(AccountEntry.Type type) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
builder.where("account_entry.type = ?");
|
||||||
|
builder.withArg(Types.VARCHAR, type.name());
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byHasAttachments(boolean hasAttachments) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String subQuery = "(SELECT COUNT(attachment_id) FROM transaction_attachment WHERE transaction_id = transaction.id)";
|
||||||
|
if (hasAttachments) {
|
||||||
|
builder.where(subQuery + " > 0");
|
||||||
|
} else {
|
||||||
|
builder.where(subQuery + " = 0");
|
||||||
|
}
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byHasLineItems(boolean hasLineItems) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String subQuery = "(SELECT COUNT(id) FROM transaction_line_item WHERE transaction_id = transaction.id)";
|
||||||
|
if (hasLineItems) {
|
||||||
|
builder.where(subQuery + " > 0");
|
||||||
|
} else {
|
||||||
|
builder.where(subQuery + " = 0");
|
||||||
|
}
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byCurrencies(Collection<Currency> currencies, boolean exclude) {
|
||||||
|
if (currencies.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String currenciesString = currencies.stream()
|
||||||
|
.map(c -> "'" + c.getCurrencyCode() + "'").distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.currency", currenciesString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAccountEntryJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("account_entry")) {
|
||||||
|
builder.withJoin("LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id");
|
||||||
|
joinTables.add("account_entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAccountJoin(SearchFilter.Builder builder) {
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
if (!joinTables.contains("account")) {
|
||||||
|
builder.withJoin("LEFT JOIN account ON account.id = account_entry.account_id");
|
||||||
|
joinTables.add("account");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCategoryJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("transaction_category")) {
|
||||||
|
builder.withJoin("LEFT JOIN transaction_category ON transaction_category.id = transaction.category_id");
|
||||||
|
joinTables.add("transaction_category");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTagJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("transaction_tag_join")) {
|
||||||
|
builder.withJoin("LEFT JOIN transaction_tag_join ON transaction_tag_join.transaction_id = transaction.id");
|
||||||
|
joinTables.add("transaction_tag_join");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addInClause(SearchFilter.Builder builder, String valueExpr, String inExpr, boolean exclude) {
|
||||||
|
if (exclude) {
|
||||||
|
builder.where(valueExpr + " NOT IN (" + inExpr + ")");
|
||||||
|
} else {
|
||||||
|
builder.where(valueExpr + " IN (" + inExpr + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.andrewlalis.perfin.data.search;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
|
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface SearchFilter {
|
||||||
|
String whereClause();
|
||||||
|
List<Pair<Integer, Object>> args();
|
||||||
|
default List<String> joinClauses() {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
|
||||||
|
|
||||||
|
class Builder {
|
||||||
|
private String whereClause;
|
||||||
|
private List<Pair<Integer, Object>> args = new ArrayList<>();
|
||||||
|
private List<String> joinClauses = new ArrayList<>();
|
||||||
|
|
||||||
|
public Builder where(String clause) {
|
||||||
|
this.whereClause = clause;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withArg(int sqlType, Object value) {
|
||||||
|
args.add(new Pair<>(sqlType, value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withArg(int value) {
|
||||||
|
return withArg(Types.INTEGER, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withArg(long value) {
|
||||||
|
return withArg(Types.BIGINT, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withArg(String value) {
|
||||||
|
return withArg(Types.VARCHAR, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withArg(LocalDateTime utcTimestamp) {
|
||||||
|
return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder withJoin(String joinClause) {
|
||||||
|
joinClauses.add(joinClause);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SearchFilter build() {
|
||||||
|
return new Impl(whereClause, args, joinClauses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@
|
||||||
|
|
||||||
package com.andrewlalis.perfin.data.ulid;
|
package com.andrewlalis.perfin.data.ulid;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
@ -50,6 +51,7 @@ import java.util.concurrent.ThreadLocalRandom;
|
||||||
*/
|
*/
|
||||||
public final class Ulid implements Serializable, Comparable<Ulid> {
|
public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
|
|
||||||
|
@Serial
|
||||||
private static final long serialVersionUID = 2625269413446854731L;
|
private static final long serialVersionUID = 2625269413446854731L;
|
||||||
|
|
||||||
private final long msb; // most significant bits
|
private final long msb; // most significant bits
|
||||||
|
@ -209,7 +211,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
* pseudo-random generator should use {@link UlidCreator#getUlid()}.
|
* pseudo-random generator should use {@link UlidCreator#getUlid()}.
|
||||||
*
|
*
|
||||||
* @return a ULID
|
* @return a ULID
|
||||||
* @see {@link ThreadLocalRandom}
|
* @see ThreadLocalRandom
|
||||||
* @since 5.1.0
|
* @since 5.1.0
|
||||||
*/
|
*/
|
||||||
public static Ulid fast() {
|
public static Ulid fast() {
|
||||||
|
@ -236,7 +238,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
* @since 5.2.0
|
* @since 5.2.0
|
||||||
*/
|
*/
|
||||||
public static Ulid min(long time) {
|
public static Ulid min(long time) {
|
||||||
return new Ulid((time << 16) | 0x0000L, 0x0000000000000000L);
|
return new Ulid((time << 16), 0x0000000000000000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.andrewlalis.perfin.data.util;
|
||||||
|
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
public class ColorUtil {
|
||||||
|
public static String toHex(Color color) {
|
||||||
|
return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatColorDouble(double val) {
|
||||||
|
String in = Integer.toHexString((int) Math.round(val * 255));
|
||||||
|
return in.length() == 1 ? "0" + in : in;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public class CurrencyUtil {
|
public class CurrencyUtil {
|
||||||
|
@ -26,4 +27,14 @@ public class CurrencyUtil {
|
||||||
BigDecimal displayValue = money.amount().setScale(money.currency().getDefaultFractionDigits(), RoundingMode.HALF_UP);
|
BigDecimal displayValue = money.amount().setScale(money.currency().getDefaultFractionDigits(), RoundingMode.HALF_UP);
|
||||||
return displayValue.toString();
|
return displayValue.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String formatMoneyValues(List<MoneyValue> values) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
final int len = values.size();
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
sb.append(formatMoneyWithCurrencyPrefix(values.get(i)));
|
||||||
|
if (i < len - 1) sb.append(", ");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,22 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setArgs(PreparedStatement stmt, Object... args) {
|
public static void setArgs(PreparedStatement stmt, Object... args) {
|
||||||
setArgs(stmt, List.of(args));
|
for (int i = 0; i < args.length; i++) {
|
||||||
|
try {
|
||||||
|
stmt.setObject(i + 1, args[i]);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args[i], e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long getGeneratedId(PreparedStatement stmt) {
|
||||||
|
try (ResultSet rs = stmt.getGeneratedKeys()) {
|
||||||
|
if (!rs.next()) throw new SQLException("No generated keys available.");
|
||||||
|
return rs.getLong(1);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||||
|
@ -58,6 +73,17 @@ public final class DbUtil {
|
||||||
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
|
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static long count(Connection conn, String query, Object... args) {
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
if (!rs.next()) throw new UncheckedSqlException("No count result available.");
|
||||||
|
return rs.getLong(1);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||||
try (var stmt = conn.prepareStatement(query)) {
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
|
@ -82,6 +108,10 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int update(Connection conn, String query, Object... args) {
|
||||||
|
return update(conn, query, List.of(args));
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
@ -92,14 +122,27 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void updateOne(Connection conn, String query, 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) {
|
public static long insertOne(Connection conn, String query, List<Object> args) {
|
||||||
|
Object[] argsArray = args.toArray();
|
||||||
|
return insertOne(conn, query, argsArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long insertOne(Connection conn, String query, Object... args) {
|
||||||
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
int result = stmt.executeUpdate();
|
int result = stmt.executeUpdate();
|
||||||
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
||||||
var rs = stmt.getGeneratedKeys();
|
return getGeneratedId(stmt);
|
||||||
rs.next();
|
|
||||||
return rs.getLong(1);
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new UncheckedSqlException(e);
|
throw new UncheckedSqlException(e);
|
||||||
}
|
}
|
||||||
|
@ -132,7 +175,9 @@ public final class DbUtil {
|
||||||
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
|
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
|
||||||
try {
|
try {
|
||||||
conn.setAutoCommit(false);
|
conn.setAutoCommit(false);
|
||||||
return supplier.offer();
|
T result = supplier.offer();
|
||||||
|
conn.commit();
|
||||||
|
return result;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try {
|
try {
|
||||||
conn.rollback();
|
conn.rollback();
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.andrewlalis.perfin.data.util;
|
package com.andrewlalis.perfin.data.util;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import javafx.stage.FileChooser;
|
import javafx.stage.FileChooser;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
@ -103,4 +104,20 @@ public class FileUtil {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public 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 String escapeCSVText(String raw) {
|
||||||
|
if (raw == null) return "NULL";
|
||||||
|
if (!raw.contains("\"") && !raw.contains(",") && !raw.contains(";")) return raw;
|
||||||
|
return '"' + raw.replaceAll("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,15 +8,18 @@ import java.util.Currency;
|
||||||
* credit-card, etc.).
|
* credit-card, etc.).
|
||||||
*/
|
*/
|
||||||
public class Account extends IdEntity {
|
public class Account extends IdEntity {
|
||||||
|
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
private final LocalDateTime createdAt;
|
private final LocalDateTime createdAt;
|
||||||
private final boolean archived;
|
private final boolean archived;
|
||||||
|
|
||||||
private AccountType type;
|
private final AccountType type;
|
||||||
private String accountNumber;
|
private final String accountNumber;
|
||||||
private String name;
|
private final String name;
|
||||||
private Currency currency;
|
private final Currency currency;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
|
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
super(id);
|
super(id);
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.archived = archived;
|
this.archived = archived;
|
||||||
|
@ -24,6 +27,7 @@ public class Account extends IdEntity {
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountType getType() {
|
public AccountType getType() {
|
||||||
|
@ -62,20 +66,8 @@ public class Account extends IdEntity {
|
||||||
return currency;
|
return currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setType(AccountType type) {
|
public String getDescription() {
|
||||||
this.type = type;
|
return description;
|
||||||
}
|
|
||||||
|
|
||||||
public void setAccountNumber(String accountNumber) {
|
|
||||||
this.accountNumber = accountNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setName(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCurrency(Currency currency) {
|
|
||||||
this.currency = currency;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import java.util.Currency;
|
||||||
* all those extra accounts would be a burden to casual users.
|
* all those extra accounts would be a burden to casual users.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class AccountEntry extends IdEntity {
|
public class AccountEntry extends IdEntity implements Timestamped {
|
||||||
public enum Type {
|
public enum Type {
|
||||||
CREDIT,
|
CREDIT,
|
||||||
DEBIT
|
DEBIT
|
||||||
|
|
|
@ -6,7 +6,8 @@ package com.andrewlalis.perfin.model;
|
||||||
public enum AccountType {
|
public enum AccountType {
|
||||||
CHECKING("Checking", true),
|
CHECKING("Checking", true),
|
||||||
SAVINGS("Savings", true),
|
SAVINGS("Savings", true),
|
||||||
CREDIT_CARD("Credit Card", false);
|
CREDIT_CARD("Credit Card", false),
|
||||||
|
BROKERAGE("Brokerage", true);
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
private final boolean debitsPositive;
|
private final boolean debitsPositive;
|
||||||
|
@ -24,14 +25,4 @@ public enum AccountType {
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AccountType parse(String s) {
|
|
||||||
s = s.strip().toUpperCase();
|
|
||||||
return switch (s) {
|
|
||||||
case "CHECKING" -> CHECKING;
|
|
||||||
case "SAVINGS" -> SAVINGS;
|
|
||||||
case "CREDIT CARD", "CREDITCARD" -> CREDIT_CARD;
|
|
||||||
default -> throw new IllegalArgumentException("Invalid AccountType string: " + s);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,20 +5,20 @@ import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recording of an account's real reported balance at a given point in time,
|
* 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 extends IdEntity {
|
public class BalanceRecord extends IdEntity implements Timestamped {
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final long accountId;
|
private final long accountId;
|
||||||
|
private final BalanceRecordType type;
|
||||||
private final BigDecimal balance;
|
private final BigDecimal balance;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
|
|
||||||
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) {
|
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency) {
|
||||||
super(id);
|
super(id);
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.accountId = accountId;
|
this.accountId = accountId;
|
||||||
|
this.type = type;
|
||||||
this.balance = balance;
|
this.balance = balance;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity {
|
||||||
return accountId;
|
return accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BalanceRecordType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal getBalance() {
|
public BigDecimal getBalance() {
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
public enum BalanceRecordType {
|
||||||
|
CASH("Cash"),
|
||||||
|
ASSETS("Assets");
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
BalanceRecordType(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,14 @@ package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple pair of accounts representing the two possible linked accounts for a
|
||||||
|
* {@link Transaction}.
|
||||||
|
* @param creditAccount The account linked as the account to which the
|
||||||
|
* transaction amount is credited.
|
||||||
|
* @param debitAccount The account linked as the account from which the
|
||||||
|
* transaction amount is debited.
|
||||||
|
*/
|
||||||
public record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
|
public record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
|
||||||
public boolean hasCredit() {
|
public boolean hasCredit() {
|
||||||
return creditAccount != null;
|
return creditAccount != null;
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record CreditCardProperties(
|
||||||
|
long accountId,
|
||||||
|
BigDecimal creditLimit
|
||||||
|
) {}
|
|
@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.PerfinApp;
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
import com.andrewlalis.perfin.data.DataSource;
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
|
||||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
|
||||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -33,37 +28,43 @@ import java.util.function.Consumer;
|
||||||
* class maintains a static <em>current</em> profile that can be loaded and
|
* class maintains a static <em>current</em> profile that can be loaded and
|
||||||
* unloaded.
|
* unloaded.
|
||||||
* </p>
|
* </p>
|
||||||
|
*
|
||||||
|
* @param name The name of the profile.
|
||||||
|
* @param settings The profile's settings.
|
||||||
|
* @param dataSource The profile's data source.
|
||||||
*/
|
*/
|
||||||
public class Profile {
|
public record Profile(String name, Properties settings, DataSource dataSource) {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Profile.class);
|
private static final Logger log = LoggerFactory.getLogger(Profile.class);
|
||||||
|
|
||||||
private static Profile current;
|
private static Profile current;
|
||||||
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
|
private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
|
||||||
|
|
||||||
private final String name;
|
public void setSettingAndSave(String settingName, String value) {
|
||||||
private final Properties settings;
|
String previous = settings.getProperty(settingName);
|
||||||
private final DataSource dataSource;
|
if (Objects.equals(previous, value)) return; // Value is already set.
|
||||||
|
settings.setProperty(settingName, value);
|
||||||
private Profile(String name, Properties settings, DataSource dataSource) {
|
try (var out = Files.newOutputStream(getSettingsFile(name))) {
|
||||||
this.name = name;
|
settings.store(out, null);
|
||||||
this.settings = settings;
|
} catch (IOException e) {
|
||||||
this.dataSource = dataSource;
|
log.error("Failed to save settings.", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public Optional<String> getSetting(String settingName) {
|
||||||
|
return Optional.ofNullable(settings.getProperty(settingName));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Properties getSettings() {
|
public static Path getProfilesDir() {
|
||||||
return settings;
|
return PerfinApp.APP_DIR.resolve("profiles");
|
||||||
}
|
|
||||||
|
|
||||||
public DataSource getDataSource() {
|
|
||||||
return dataSource;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Path getDir(String name) {
|
public static Path getDir(String name) {
|
||||||
return PerfinApp.APP_DIR.resolve(name);
|
return getProfilesDir().resolve(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Path getContentDir(String name) {
|
public static Path getContentDir(String name) {
|
||||||
|
@ -78,89 +79,23 @@ public class Profile {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void setCurrent(Profile profile) {
|
||||||
|
current = profile;
|
||||||
|
for (var ref : currentProfileListeners) {
|
||||||
|
Consumer<Profile> consumer = ref.get();
|
||||||
|
if (consumer != null) {
|
||||||
|
consumer.accept(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentProfileListeners.removeIf(ref -> ref.get() == null);
|
||||||
|
log.debug("Current profile set to {}.", current.name());
|
||||||
|
}
|
||||||
|
|
||||||
public static void whenLoaded(Consumer<Profile> consumer) {
|
public static void whenLoaded(Consumer<Profile> consumer) {
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
consumer.accept(current);
|
consumer.accept(current);
|
||||||
} else {
|
|
||||||
profileLoadListeners.add(consumer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<String> getAvailableProfiles() {
|
|
||||||
try (var files = Files.list(PerfinApp.APP_DIR)) {
|
|
||||||
return files.filter(Files::isDirectory)
|
|
||||||
.map(path -> path.getFileName().toString())
|
|
||||||
.sorted().toList();
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Failed to get a list of available profiles.", e);
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
log.error("Failed to read " + lastProfileFile, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
log.error("Failed to write " + lastProfileFile, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void loadLast() throws ProfileLoadException {
|
|
||||||
load(getLastProfile());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void load(String name) throws ProfileLoadException {
|
|
||||||
if (Files.notExists(getDir(name))) {
|
|
||||||
try {
|
|
||||||
initProfileDir(name);
|
|
||||||
} catch (IOException e) {
|
|
||||||
FileUtil.deleteIfPossible(getDir(name));
|
|
||||||
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Properties settings = new Properties();
|
|
||||||
try (var in = Files.newInputStream(getSettingsFile(name))) {
|
|
||||||
settings.load(in);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ProfileLoadException("Failed to load profile settings.", e);
|
|
||||||
}
|
|
||||||
current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
|
|
||||||
saveLastProfile(current.getName());
|
|
||||||
for (var c : profileLoadListeners) {
|
|
||||||
c.accept(current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
}
|
}
|
||||||
|
currentProfileListeners.add(new WeakReference<>(consumer));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean validateName(String name) {
|
public static boolean validateName(String name) {
|
||||||
|
@ -168,9 +103,4 @@ public class Profile {
|
||||||
name.matches("\\w+") &&
|
name.matches("\\w+") &&
|
||||||
name.toLowerCase().equals(name);
|
name.toLowerCase().equals(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class with static methods for managing backups of profiles.
|
||||||
|
*/
|
||||||
|
public class ProfileBackups {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ProfileBackups.class);
|
||||||
|
|
||||||
|
public static Path getBackupDir(String profileName) {
|
||||||
|
return PerfinApp.APP_DIR.resolve("backups").resolve(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path makeBackup(String name) throws IOException {
|
||||||
|
log.info("Making backup of profile \"{}\".", name);
|
||||||
|
final Path profileDir = Profile.getDir(name);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
Files.createDirectories(getBackupDir(name));
|
||||||
|
Path backupFile = getBackupDir(name).resolve(String.format(
|
||||||
|
"%04d-%02d-%02d_%02d-%02d-%02d.zip",
|
||||||
|
now.getYear(), now.getMonthValue(), now.getDayOfMonth(),
|
||||||
|
now.getHour(), now.getMinute(), now.getSecond()
|
||||||
|
));
|
||||||
|
try (var out = new ZipOutputStream(Files.newOutputStream(backupFile))) {
|
||||||
|
Files.walkFileTree(profileDir, new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||||
|
Path relativeFile = profileDir.relativize(file);
|
||||||
|
out.putNextEntry(new ZipEntry(relativeFile.toString()));
|
||||||
|
byte[] bytes = Files.readAllBytes(file);
|
||||||
|
out.write(bytes, 0, bytes.length);
|
||||||
|
out.closeEntry();
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return backupFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LocalDateTime getLastBackupTimestamp(String name) {
|
||||||
|
if (Files.notExists(getBackupDir(name))) return null;
|
||||||
|
try (var files = Files.list(getBackupDir(name))) {
|
||||||
|
return files.map(ProfileBackups::getTimestampFromBackup)
|
||||||
|
.max(LocalDateTime::compareTo)
|
||||||
|
.orElse(null);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to list files in profile " + name, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void cleanOldBackups(String name) {
|
||||||
|
final LocalDateTime cutoff = LocalDateTime.now().minusDays(30);
|
||||||
|
try (var files = Files.list(getBackupDir(name))) {
|
||||||
|
var filesToDelete = files.filter(path -> {
|
||||||
|
LocalDateTime timestamp = getTimestampFromBackup(path);
|
||||||
|
return timestamp.isBefore(cutoff);
|
||||||
|
}).toList();
|
||||||
|
for (var file : filesToDelete) {
|
||||||
|
Files.delete(file);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to cleanup backups.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime getTimestampFromBackup(Path backupFile) {
|
||||||
|
String text = backupFile.getFileName().toString().substring(0, "0000-00-00_00-00-00".length());
|
||||||
|
return LocalDateTime.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
|
import com.andrewlalis.perfin.control.Popups;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.DataSourceFactory;
|
||||||
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
|
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
||||||
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
|
import javafx.stage.Window;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component responsible for loading a profile from storage, as well as some
|
||||||
|
* other basic tasks concerning the set of stored profiles.
|
||||||
|
*/
|
||||||
|
public class ProfileLoader {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
|
||||||
|
|
||||||
|
private final Window window;
|
||||||
|
private final DataSourceFactory dataSourceFactory;
|
||||||
|
|
||||||
|
public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
|
||||||
|
this.window = window;
|
||||||
|
this.dataSourceFactory = dataSourceFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Profile load(String name) throws ProfileLoadException {
|
||||||
|
if (Files.notExists(Profile.getDir(name))) {
|
||||||
|
try {
|
||||||
|
initProfileDir(name);
|
||||||
|
} catch (IOException e) {
|
||||||
|
FileUtil.deleteIfPossible(Profile.getDir(name));
|
||||||
|
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Properties settings = new Properties();
|
||||||
|
try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
|
||||||
|
settings.load(in);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ProfileLoadException("Failed to load profile settings.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to check the profile's schema version and migrate if needed.
|
||||||
|
try {
|
||||||
|
DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
|
||||||
|
if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
|
||||||
|
boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
|
||||||
|
if (!confirm) {
|
||||||
|
int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
|
||||||
|
String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
|
||||||
|
Popups.message(
|
||||||
|
window,
|
||||||
|
"The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
|
||||||
|
);
|
||||||
|
throw new ProfileLoadException("User rejected the migration.");
|
||||||
|
}
|
||||||
|
} else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
|
||||||
|
Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
|
||||||
|
throw new ProfileLoadException("Incompatible schema version.");
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ProfileLoadException("Failed to get profile's schema status.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a recent backup and make one if not present.
|
||||||
|
LocalDateTime lastBackup = ProfileBackups.getLastBackupTimestamp(name);
|
||||||
|
if (lastBackup == null || lastBackup.isBefore(LocalDateTime.now().minusDays(1))) {
|
||||||
|
try {
|
||||||
|
ProfileBackups.makeBackup(name);
|
||||||
|
ProfileBackups.cleanOldBackups(name);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to create backup for profile " + name + ".", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DataSource dataSource = dataSourceFactory.getDataSource(name);
|
||||||
|
return new Profile(name, settings, dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> getAvailableProfiles() {
|
||||||
|
try (var files = Files.list(Profile.getProfilesDir())) {
|
||||||
|
return files.filter(Files::isDirectory)
|
||||||
|
.filter(p -> !p.getFileName().toString().startsWith("."))
|
||||||
|
.map(path -> path.getFileName().toString())
|
||||||
|
.sorted().toList();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to get a list of available profiles.", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
log.error("Failed to read " + lastProfileFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
log.error("Failed to write " + lastProfileFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
private static void initProfileDir(String name) throws IOException {
|
||||||
|
Files.createDirectory(Profile.getDir(name));
|
||||||
|
copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
|
||||||
|
copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
|
||||||
|
Files.createDirectory(Profile.getContentDir(name));
|
||||||
|
copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public interface Timestamped {
|
||||||
|
/**
|
||||||
|
* Gets the timestamp at which the entity was created, in UTC timezone.
|
||||||
|
* @return The UTC timestamp at which this entity was created.
|
||||||
|
*/
|
||||||
|
LocalDateTime getTimestamp();
|
||||||
|
|
||||||
|
record Stub(long id, LocalDateTime timestamp) implements Timestamped {
|
||||||
|
@Override
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Stub fromResultSet(ResultSet rs) throws SQLException {
|
||||||
|
return new Stub(rs.getLong(1), DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,18 +12,22 @@ import java.util.Currency;
|
||||||
* actual positive/negative effect is determined by the associated account
|
* actual positive/negative effect is determined by the associated account
|
||||||
* entries that apply this transaction's amount to one or more accounts.
|
* entries that apply this transaction's amount to one or more accounts.
|
||||||
*/
|
*/
|
||||||
public class Transaction extends IdEntity {
|
public class Transaction extends IdEntity implements Timestamped {
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final BigDecimal amount;
|
private final BigDecimal amount;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
private final Long vendorId;
|
||||||
|
private final Long categoryId;
|
||||||
|
|
||||||
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
|
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
|
||||||
super(id);
|
super(id);
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
this.vendorId = vendorId;
|
||||||
|
this.categoryId = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getTimestamp() {
|
public LocalDateTime getTimestamp() {
|
||||||
|
@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getVendorId() {
|
||||||
|
return vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
public MoneyValue getMoneyAmount() {
|
public MoneyValue getMoneyAmount() {
|
||||||
return new MoneyValue(amount, currency);
|
return new MoneyValue(amount, currency);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
public class TransactionCategory extends IdEntity {
|
||||||
|
public static final int NAME_MAX_LENGTH = 63;
|
||||||
|
|
||||||
|
private final Long parentId;
|
||||||
|
private final String name;
|
||||||
|
private final Color color;
|
||||||
|
|
||||||
|
public TransactionCategory(long id, Long parentId, String name, Color color) {
|
||||||
|
super(id);
|
||||||
|
this.parentId = parentId;
|
||||||
|
this.name = name;
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getParentId() {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getColor() {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line item that comprises part of a transaction. Its total value (value per
|
||||||
|
* item * quantity) is part of the transaction's total value. It can be used to
|
||||||
|
* record some transactions, like purchases and invoices, in more granular
|
||||||
|
* detail.
|
||||||
|
*/
|
||||||
|
public class TransactionLineItem extends IdEntity {
|
||||||
|
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
private final long transactionId;
|
||||||
|
private final BigDecimal valuePerItem;
|
||||||
|
private final int quantity;
|
||||||
|
private final int idx;
|
||||||
|
private final String description;
|
||||||
|
private final Long categoryId;
|
||||||
|
|
||||||
|
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description, Long categoryId) {
|
||||||
|
super(id);
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
this.valuePerItem = valuePerItem;
|
||||||
|
this.quantity = quantity;
|
||||||
|
this.idx = idx;
|
||||||
|
this.description = description;
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTransactionId() {
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getValuePerItem() {
|
||||||
|
return valuePerItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQuantity() {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIdx() {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTotalValue() {
|
||||||
|
return valuePerItem.multiply(new BigDecimal(quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format(
|
||||||
|
"TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
|
||||||
|
id,
|
||||||
|
transactionId,
|
||||||
|
valuePerItem.toPlainString(),
|
||||||
|
quantity,
|
||||||
|
idx,
|
||||||
|
description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tag that can be applied to a transaction to add some user-defined semantic
|
||||||
|
* meaning to it.
|
||||||
|
*/
|
||||||
|
public class TransactionTag extends IdEntity {
|
||||||
|
public static final int NAME_MAX_LENGTH = 63;
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
public TransactionTag(long id, String name) {
|
||||||
|
super(id);
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vendor is a business establishment that can be linked to a transaction, to
|
||||||
|
* denote the business that the transaction took place with.
|
||||||
|
*/
|
||||||
|
public class TransactionVendor extends IdEntity {
|
||||||
|
public static final int NAME_MAX_LENGTH = 255;
|
||||||
|
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
public TransactionVendor(long id, String name, String description) {
|
||||||
|
super(id);
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,36 +0,0 @@
|
||||||
package com.andrewlalis.perfin.model.history;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.model.IdEntity;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The base class representing account history items, a read-only record of an
|
|
||||||
* account's data and changes over time. The type of history item determines
|
|
||||||
* what exactly it means, and could be something like an account entry, balance
|
|
||||||
* record, or modifications to the account's properties.
|
|
||||||
*/
|
|
||||||
public class AccountHistoryItem extends IdEntity {
|
|
||||||
private final LocalDateTime timestamp;
|
|
||||||
private final long accountId;
|
|
||||||
private final AccountHistoryItemType type;
|
|
||||||
|
|
||||||
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
|
||||||
super(id);
|
|
||||||
this.timestamp = timestamp;
|
|
||||||
this.accountId = accountId;
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getTimestamp() {
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getAccountId() {
|
|
||||||
return accountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccountHistoryItemType getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package com.andrewlalis.perfin.model.history;
|
|
||||||
|
|
||||||
public enum AccountHistoryItemType {
|
|
||||||
TEXT,
|
|
||||||
ACCOUNT_ENTRY,
|
|
||||||
BALANCE_RECORD
|
|
||||||
}
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.andrewlalis.perfin.model.history;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.IdEntity;
|
||||||
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single polymorphic history item. The item's "type" attribute
|
||||||
|
* tells where to find additional type-specific data.
|
||||||
|
*/
|
||||||
|
public abstract class HistoryItem extends IdEntity implements Timestamped {
|
||||||
|
public enum Type {
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
private final long historyId;
|
||||||
|
private final LocalDateTime timestamp;
|
||||||
|
private final Type type;
|
||||||
|
|
||||||
|
public HistoryItem(long id, long historyId, LocalDateTime timestamp, Type type) {
|
||||||
|
super(id);
|
||||||
|
this.historyId = historyId;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getHistoryId() {
|
||||||
|
return historyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.andrewlalis.perfin.model.history;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class HistoryTextItem extends HistoryItem {
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
|
||||||
|
super(id, historyId, timestamp, HistoryItem.Type.TEXT);
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,10 @@
|
||||||
package com.andrewlalis.perfin.view;
|
package com.andrewlalis.perfin.view;
|
||||||
|
|
||||||
import javafx.beans.WeakListener;
|
import javafx.beans.WeakListener;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.collections.ListChangeListener;
|
import javafx.collections.ListChangeListener;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -86,4 +88,9 @@ public class BindingUtil {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
|
||||||
|
node.managedProperty().bind(node.visibleProperty());
|
||||||
|
node.visibleProperty().bind(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import javafx.stage.Stage;
|
||||||
import javafx.stage.StageStyle;
|
import javafx.stage.StageStyle;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -17,12 +18,14 @@ import java.util.function.Consumer;
|
||||||
*/
|
*/
|
||||||
public class StartupSplashScreen extends Stage implements Consumer<String> {
|
public class StartupSplashScreen extends Stage implements Consumer<String> {
|
||||||
private final List<ThrowableConsumer<Consumer<String>>> tasks;
|
private final List<ThrowableConsumer<Consumer<String>>> tasks;
|
||||||
|
private final boolean delayTasks;
|
||||||
private boolean startupSuccessful = false;
|
private boolean startupSuccessful = false;
|
||||||
|
|
||||||
private final TextArea textArea = new TextArea();
|
private final TextArea textArea = new TextArea();
|
||||||
|
|
||||||
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
|
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
|
||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
|
this.delayTasks = delayTasks;
|
||||||
setTitle("Starting Perfin...");
|
setTitle("Starting Perfin...");
|
||||||
setResizable(false);
|
setResizable(false);
|
||||||
initStyle(StageStyle.UNDECORATED);
|
initStyle(StageStyle.UNDECORATED);
|
||||||
|
@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs all tasks sequentially, invoking each one on the JavaFX main thread,
|
||||||
|
* and quitting if there's any exception thrown.
|
||||||
|
*/
|
||||||
private void runTasks() {
|
private void runTasks() {
|
||||||
Thread.ofVirtual().start(() -> {
|
Thread.ofVirtual().start(() -> {
|
||||||
try {
|
if (delayTasks) sleepOrThrowRE(1000);
|
||||||
Thread.sleep(1000);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
for (var task : tasks) {
|
for (var task : tasks) {
|
||||||
try {
|
try {
|
||||||
task.accept(this);
|
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||||
Thread.sleep(500);
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.accept(this);
|
||||||
|
future.complete(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
future.completeExceptionally(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
future.join();
|
||||||
|
if (delayTasks) sleepOrThrowRE(500);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
accept("Startup failed: " + e.getMessage());
|
accept("Startup failed: " + e.getMessage());
|
||||||
e.printStackTrace(System.err);
|
e.printStackTrace(System.err);
|
||||||
try {
|
sleepOrThrowRE(5000);
|
||||||
Thread.sleep(5000);
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
throw new RuntimeException(ex);
|
|
||||||
}
|
|
||||||
Platform.runLater(this::close);
|
Platform.runLater(this::close);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
accept("Startup successful!");
|
accept("Startup successful!");
|
||||||
try {
|
if (delayTasks) sleepOrThrowRE(1000);
|
||||||
Thread.sleep(1000);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
startupSuccessful = true;
|
startupSuccessful = true;
|
||||||
Platform.runLater(this::close);
|
Platform.runLater(this::close);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to sleep the current thread or throw a runtime exception.
|
||||||
|
* @param ms The number of milliseconds to sleep for.
|
||||||
|
*/
|
||||||
|
private static void sleepOrThrowRE(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.control.TransactionsViewController;
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
import javafx.scene.control.Hyperlink;
|
|
||||||
import javafx.scene.text.Text;
|
|
||||||
import javafx.scene.text.TextFlow;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
|
||||||
|
|
||||||
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
|
|
||||||
public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
|
|
||||||
super(item);
|
|
||||||
AccountEntry entry = repo.getAccountEntryItem(item.id);
|
|
||||||
if (entry == null) {
|
|
||||||
setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
|
|
||||||
Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
|
|
||||||
transactionLink.setOnAction(event -> router.navigate(
|
|
||||||
"transactions",
|
|
||||||
new TransactionsViewController.RouteContext(entry.getTransactionId())
|
|
||||||
));
|
|
||||||
var text = new TextFlow(
|
|
||||||
transactionLink,
|
|
||||||
new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
|
|
||||||
amountText
|
|
||||||
);
|
|
||||||
setCenter(text);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.control.AccountViewController;
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
import javafx.scene.control.Hyperlink;
|
|
||||||
import javafx.scene.text.Text;
|
|
||||||
import javafx.scene.text.TextFlow;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
|
||||||
|
|
||||||
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
|
|
||||||
public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
|
|
||||||
super(item);
|
|
||||||
BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
|
|
||||||
if (balanceRecord == null) {
|
|
||||||
setCenter(new TextFlow(new Text("Deleted balance record was added.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
|
|
||||||
var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
|
|
||||||
setCenter(text);
|
|
||||||
|
|
||||||
Hyperlink viewLink = new Hyperlink("View this balance record");
|
|
||||||
viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
|
|
||||||
setBottom(viewLink);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.control.AccountViewController;
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
import javafx.scene.control.Label;
|
|
||||||
import javafx.scene.layout.BorderPane;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tile that shows a brief bit of information about an account history item.
|
|
||||||
*/
|
|
||||||
public abstract class AccountHistoryItemTile extends BorderPane {
|
|
||||||
public AccountHistoryItemTile(AccountHistoryItem item) {
|
|
||||||
getStyleClass().add("tile");
|
|
||||||
|
|
||||||
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
|
|
||||||
timestampLabel.getStyleClass().add("small-font");
|
|
||||||
setTop(timestampLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AccountHistoryItemTile forItem(
|
|
||||||
AccountHistoryItem item,
|
|
||||||
AccountHistoryItemRepository repo,
|
|
||||||
AccountViewController controller
|
|
||||||
) {
|
|
||||||
return switch (item.getType()) {
|
|
||||||
case TEXT -> new AccountHistoryTextTile(item, repo);
|
|
||||||
case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo);
|
|
||||||
case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
|
||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
|
||||||
import javafx.scene.text.Text;
|
|
||||||
import javafx.scene.text.TextFlow;
|
|
||||||
|
|
||||||
public class AccountHistoryTextTile extends AccountHistoryItemTile {
|
|
||||||
public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
|
|
||||||
super(item);
|
|
||||||
String text = repo.getTextItem(item.id);
|
|
||||||
setCenter(new TextFlow(new Text(text)));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class AccountHistoryTile extends VBox {
|
||||||
|
public AccountHistoryTile(LocalDateTime timestamp, Node centerContent) {
|
||||||
|
getStyleClass().add("history-tile");
|
||||||
|
|
||||||
|
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(timestamp));
|
||||||
|
timestampLabel.getStyleClass().addAll("small-font", "mono-font", "secondary-color-text-fill");
|
||||||
|
getChildren().add(timestampLabel);
|
||||||
|
getChildren().add(centerContent);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||||
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.*;
|
||||||
|
import javafx.geometry.Orientation;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Hyperlink;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.Separator;
|
||||||
|
import javafx.scene.layout.AnchorPane;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class AccountHistoryView extends ScrollPane {
|
||||||
|
private LocalDateTime lastTimestamp = null;
|
||||||
|
private final BooleanProperty canLoadMore = new SimpleBooleanProperty(true);
|
||||||
|
private final VBox itemsVBox = new VBox();
|
||||||
|
private final LongProperty accountIdProperty = new SimpleLongProperty(-1L);
|
||||||
|
private final IntegerProperty initialItemsToLoadProperty = new SimpleIntegerProperty(10);
|
||||||
|
|
||||||
|
public AccountHistoryView() {
|
||||||
|
VBox scrollableContentVBox = new VBox();
|
||||||
|
scrollableContentVBox.getChildren().add(itemsVBox);
|
||||||
|
itemsVBox.setMinWidth(0);
|
||||||
|
|
||||||
|
Hyperlink loadMoreLink = new Hyperlink("Load more history");
|
||||||
|
loadMoreLink.setOnAction(event -> loadMoreHistory());
|
||||||
|
BindingUtil.bindManagedAndVisible(loadMoreLink, canLoadMore);
|
||||||
|
|
||||||
|
scrollableContentVBox.getChildren().add(new BorderPane(loadMoreLink));
|
||||||
|
itemsVBox.getStyleClass().addAll("tile-container");
|
||||||
|
this.setContent(scrollableContentVBox);
|
||||||
|
this.setFitToHeight(true);
|
||||||
|
this.setFitToWidth(true);
|
||||||
|
this.setHbarPolicy(ScrollBarPolicy.AS_NEEDED);
|
||||||
|
this.setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadMoreHistory() {
|
||||||
|
long accountId = accountIdProperty.get();
|
||||||
|
int maxItems = initialItemsToLoadProperty.get();
|
||||||
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
|
ds.mapRepoAsync(AccountRepository.class, repo -> repo.findEventsBefore(accountId, lastTimestamp(), maxItems))
|
||||||
|
.thenAccept(entities -> addEntitiesToHistory(entities, maxItems));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
itemsVBox.getChildren().clear();
|
||||||
|
canLoadMore.set(true);
|
||||||
|
lastTimestamp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountId(long accountId) {
|
||||||
|
this.accountIdProperty.set(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property methods
|
||||||
|
public final IntegerProperty initialItemsToLoadProperty() {
|
||||||
|
return initialItemsToLoadProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final int getInitialItemsToLoad() {
|
||||||
|
return initialItemsToLoadProperty.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setInitialItemsToLoad(int value) {
|
||||||
|
initialItemsToLoadProperty.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime lastTimestamp() {
|
||||||
|
if (lastTimestamp == null) return DateUtil.nowAsUTC();
|
||||||
|
return lastTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Node> makeTile(Timestamped entity) {
|
||||||
|
switch (entity) {
|
||||||
|
case HistoryTextItem textItem -> {
|
||||||
|
return CompletableFuture.completedFuture(
|
||||||
|
new AccountHistoryTile(textItem.getTimestamp(), new TextFlow(new Text(textItem.getDescription())))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case AccountEntry ae -> {
|
||||||
|
Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId());
|
||||||
|
txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId())));
|
||||||
|
String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT
|
||||||
|
? "credited %s from this account"
|
||||||
|
: "debited %s to this account";
|
||||||
|
final String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
||||||
|
|
||||||
|
CompletableFuture<Node> future = new CompletableFuture<>();
|
||||||
|
Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
||||||
|
Optional<Transaction> optionalTransaction = repo.findById(ae.getTransactionId());
|
||||||
|
String extraText = optionalTransaction.map(transaction -> ": " + transaction.getDescription())
|
||||||
|
.orElse(". No transaction information found.");
|
||||||
|
TextFlow textFlow = new TextFlow(txLink, new Text(description + extraText));
|
||||||
|
future.complete(new AccountHistoryTile(ae.getTimestamp(), textFlow));
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
|
||||||
|
}
|
||||||
|
case BalanceRecord br -> {
|
||||||
|
Hyperlink brLink = new Hyperlink("Balance Record #" + br.id);
|
||||||
|
brLink.setOnAction(event -> router.navigate("balance-record", br));
|
||||||
|
String phrase = switch(br.getType()) {
|
||||||
|
case CASH -> "a cash value";
|
||||||
|
case ASSETS -> "an asset value";
|
||||||
|
};
|
||||||
|
return CompletableFuture.completedFuture(new AccountHistoryTile(br.getTimestamp(), new TextFlow(
|
||||||
|
brLink,
|
||||||
|
new Text("added with %s of %s.".formatted(phrase, CurrencyUtil.formatMoney(br.getMoneyAmount())))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
return CompletableFuture.completedFuture(
|
||||||
|
new AccountHistoryTile(entity.getTimestamp(), new TextFlow(new Text("Unsupported entity: " + entity.getClass().getName())))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addEntitiesToHistory(List<Timestamped> entities, int requestedItems) {
|
||||||
|
var futures = entities.stream().map(this::makeTile).toList();
|
||||||
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||||
|
.thenRun(() -> {
|
||||||
|
List<AnchorPane> tiles = futures.stream().map(CompletableFuture::join)
|
||||||
|
.map(tile -> {
|
||||||
|
// Use this to scrunch content to the left.
|
||||||
|
AnchorPane ap = new AnchorPane(tile);
|
||||||
|
AnchorPane.setLeftAnchor(tile, 0.0);
|
||||||
|
return ap;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (!itemsVBox.getChildren().isEmpty()) {
|
||||||
|
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
||||||
|
}
|
||||||
|
itemsVBox.getChildren().addAll(tiles);
|
||||||
|
if (entities.size() < requestedItems) {
|
||||||
|
canLoadMore.set(false);
|
||||||
|
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
||||||
|
endMarker.getStyleClass().addAll("large-font", "italic-text");
|
||||||
|
itemsVBox.getChildren().add(endMarker);
|
||||||
|
}
|
||||||
|
if (!entities.isEmpty()) {
|
||||||
|
lastTimestamp = entities.getLast().getTimestamp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
||||||
showBalanceProperty.set(value);
|
showBalanceProperty.set(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> {
|
/**
|
||||||
private final BooleanProperty showBalanceProp;
|
* A simple cell factory that just returns instances of {@link AccountListCell}.
|
||||||
|
* @param showBalanceProp Whether to show the account's balance.
|
||||||
private CellFactory(BooleanProperty showBalanceProp) {
|
*/
|
||||||
this.showBalanceProp = showBalanceProp;
|
private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ListCell<Account> call(ListView<Account> param) {
|
public ListCell<Account> call(ListView<Account> param) {
|
||||||
return new AccountListCell(showBalanceProp);
|
return new AccountListCell(showBalanceProp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list cell implementation which shows an account's name, and optionally,
|
||||||
|
* its current derived balance underneath.
|
||||||
|
*/
|
||||||
private static class AccountListCell extends ListCell<Account> {
|
private static class AccountListCell extends ListCell<Account> {
|
||||||
private final BooleanProperty showBalanceProp;
|
private final BooleanProperty showBalanceProp;
|
||||||
private final Label nameLabel = new Label();
|
private final Label nameLabel = new Label();
|
||||||
|
@ -110,8 +112,8 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
||||||
|
|
||||||
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
||||||
if (showBalanceProp.get()) {
|
if (showBalanceProp.get()) {
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal balance = repo.deriveCurrentBalance(item.id);
|
BigDecimal balance = repo.deriveCurrentCashBalance(item.id);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
||||||
balanceLabel.setVisible(true);
|
balanceLabel.setVisible(true);
|
||||||
|
|
|
@ -17,6 +17,7 @@ import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
@ -25,10 +26,11 @@ import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
* A compact tile that displays information about an account.
|
* A compact tile that displays information about an account.
|
||||||
*/
|
*/
|
||||||
public class AccountTile extends BorderPane {
|
public class AccountTile extends BorderPane {
|
||||||
private static final Map<AccountType, String> ACCOUNT_TYPE_COLORS = Map.of(
|
public static final Map<AccountType, String> ACCOUNT_TYPE_COLORS = Map.of(
|
||||||
AccountType.CHECKING, "-fx-theme-account-type-checking",
|
AccountType.CHECKING, "-fx-theme-account-type-checking",
|
||||||
AccountType.SAVINGS, "-fx-theme-account-type-savings",
|
AccountType.SAVINGS, "-fx-theme-account-type-savings",
|
||||||
AccountType.CREDIT_CARD, "-fx-theme-account-type-credit-card"
|
AccountType.CREDIT_CARD, "-fx-theme-account-type-credit-card",
|
||||||
|
AccountType.BROKERAGE, "-fx-theme-account-type-brokerage"
|
||||||
);
|
);
|
||||||
|
|
||||||
public AccountTile(Account account) {
|
public AccountTile(Account account) {
|
||||||
|
@ -81,8 +83,8 @@ public class AccountTile extends BorderPane {
|
||||||
Label balanceLabel = new Label("Computing balance...");
|
Label balanceLabel = new Label("Computing balance...");
|
||||||
balanceLabel.getStyleClass().addAll("mono-font");
|
balanceLabel.getStyleClass().addAll("mono-font");
|
||||||
balanceLabel.setDisable(true);
|
balanceLabel.setDisable(true);
|
||||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
|
||||||
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
|
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
balanceLabel.setText(text);
|
balanceLabel.setText(text);
|
||||||
|
@ -103,6 +105,32 @@ public class AccountTile extends BorderPane {
|
||||||
newPropertyLabel("Current Balance"),
|
newPropertyLabel("Current Balance"),
|
||||||
balanceLabel
|
balanceLabel
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (account.getType() == AccountType.BROKERAGE) {
|
||||||
|
Label assetValueLabel = new Label("Computing assets value...");
|
||||||
|
assetValueLabel.getStyleClass().addAll("mono-font");
|
||||||
|
assetValueLabel.setDisable(true);
|
||||||
|
|
||||||
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
|
BigDecimal assetValue = repo.getNearestAssetValue(account.id);
|
||||||
|
String text = CurrencyUtil.formatMoney(new MoneyValue(assetValue, account.getCurrency()));
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
assetValueLabel.setText(text);
|
||||||
|
if (account.getType().areDebitsPositive() && assetValue.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
assetValueLabel.getStyleClass().add("negative-color-text-fill");
|
||||||
|
} else if (!account.getType().areDebitsPositive() && assetValue.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
assetValueLabel.getStyleClass().add("positive-color-text-fill");
|
||||||
|
}
|
||||||
|
assetValueLabel.setDisable(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
newPropertyLabel("Latest Assets Value"),
|
||||||
|
assetValueLabel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return propertiesPane;
|
return propertiesPane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
|
||||||
boolean showDocIcon = true;
|
boolean showDocIcon = true;
|
||||||
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
|
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
|
||||||
if (imageTypes.contains(attachment.getContentType())) {
|
if (imageTypes.contains(attachment.getContentType())) {
|
||||||
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
|
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().name())))) {
|
||||||
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
||||||
contentContainer.setCenter(new ImageView(img));
|
contentContainer.setCenter(new ImageView(img));
|
||||||
showDocIcon = false;
|
showDocIcon = false;
|
||||||
|
@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane {
|
||||||
this.setCenter(stackPane);
|
this.setCenter(stackPane);
|
||||||
this.setOnMouseClicked(event -> {
|
this.setOnMouseClicked(event -> {
|
||||||
if (this.isHover()) {
|
if (this.isHover()) {
|
||||||
Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName()));
|
Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().name()));
|
||||||
PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
|
PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
|
public class CategoryLabel extends HBox {
|
||||||
|
public CategoryLabel(TransactionCategory category) {
|
||||||
|
this(category, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CategoryLabel(TransactionCategory category, double indicatorSize) {
|
||||||
|
Circle colorIndicator = new Circle(8, category.getColor());
|
||||||
|
Label label = new Label(category.getName());
|
||||||
|
this.getChildren().addAll(colorIndicator, label);
|
||||||
|
this.getStyleClass().add("std-spacing");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ListCell;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class CategorySelectionBox extends ComboBox<TransactionCategory> {
|
||||||
|
private final Map<TransactionCategory, Integer> categoryIndentationLevels = new HashMap<>();
|
||||||
|
|
||||||
|
public CategorySelectionBox() {
|
||||||
|
setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
|
||||||
|
setButtonCell(new CategoryListCell(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadCategories(List<TransactionCategoryRepository.CategoryTreeNode> treeNodes) {
|
||||||
|
categoryIndentationLevels.clear();
|
||||||
|
getItems().clear();
|
||||||
|
populateCategories(treeNodes, 0);
|
||||||
|
getItems().add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateCategories(
|
||||||
|
List<TransactionCategoryRepository.CategoryTreeNode> treeNodes,
|
||||||
|
int depth
|
||||||
|
) {
|
||||||
|
for (var node : treeNodes) {
|
||||||
|
getItems().add(node.category());
|
||||||
|
categoryIndentationLevels.put(node.category(), depth);
|
||||||
|
populateCategories(node.children(), depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void select(TransactionCategory category) {
|
||||||
|
setButtonCell(new CategoryListCell(null));
|
||||||
|
getSelectionModel().select(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class CategoryListCell extends ListCell<TransactionCategory> {
|
||||||
|
private final Label nameLabel = new Label();
|
||||||
|
private final Circle colorIndicator = new Circle(8);
|
||||||
|
private final Map<TransactionCategory, Integer> categoryIndentationLevels;
|
||||||
|
|
||||||
|
public CategoryListCell(Map<TransactionCategory, Integer> categoryIndentationLevels) {
|
||||||
|
this.categoryIndentationLevels = categoryIndentationLevels;
|
||||||
|
nameLabel.getStyleClass().add("normal-color-text-fill");
|
||||||
|
colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
|
||||||
|
HBox container = new HBox(colorIndicator, nameLabel);
|
||||||
|
container.getStyleClass().add("std-spacing");
|
||||||
|
setGraphic(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(TransactionCategory item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (item == null || empty) {
|
||||||
|
nameLabel.setText("None");
|
||||||
|
colorIndicator.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameLabel.setText(item.getName());
|
||||||
|
if (categoryIndentationLevels != null) {
|
||||||
|
HBox.setMargin(
|
||||||
|
colorIndicator,
|
||||||
|
new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
colorIndicator.setVisible(true);
|
||||||
|
colorIndicator.setFill(item.getColor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.control.EditCategoryController;
|
||||||
|
import com.andrewlalis.perfin.control.Popups;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class CategoryTile extends VBox {
|
||||||
|
public CategoryTile(
|
||||||
|
TransactionCategoryRepository.CategoryTreeNode treeNode,
|
||||||
|
Runnable categoriesRefresh
|
||||||
|
) {
|
||||||
|
this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor");
|
||||||
|
this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
|
||||||
|
this.setOnMouseClicked(event -> {
|
||||||
|
event.consume();
|
||||||
|
router.navigate(
|
||||||
|
"edit-category",
|
||||||
|
new EditCategoryController.CategoryRouteContext(treeNode.category())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BorderPane borderPane = new BorderPane();
|
||||||
|
borderPane.getStyleClass().addAll("std-padding");
|
||||||
|
Label nameLabel = new Label(treeNode.category().getName());
|
||||||
|
nameLabel.getStyleClass().addAll("bold-text");
|
||||||
|
Circle colorCircle = new Circle(10, treeNode.category().getColor());
|
||||||
|
HBox contentBox = new HBox(colorCircle, nameLabel);
|
||||||
|
contentBox.getStyleClass().addAll("std-spacing");
|
||||||
|
borderPane.setLeft(contentBox);
|
||||||
|
|
||||||
|
Button addChildButton = new Button("Add Subcategory");
|
||||||
|
addChildButton.setOnAction(event -> router.navigate(
|
||||||
|
"edit-category",
|
||||||
|
new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
|
||||||
|
));
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setOnAction(event -> {
|
||||||
|
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
|
||||||
|
if (confirm) {
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.deleteById(treeNode.category().id)
|
||||||
|
);
|
||||||
|
categoriesRefresh.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
HBox buttonsBox = new HBox(addChildButton, removeButton);
|
||||||
|
buttonsBox.getStyleClass().addAll("std-spacing");
|
||||||
|
borderPane.setRight(buttonsBox);
|
||||||
|
|
||||||
|
this.getChildren().add(borderPane);
|
||||||
|
for (var child : treeNode.children()) {
|
||||||
|
this.getChildren().add(new CategoryTile(child, categoriesRefresh));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.helpRouter;
|
import static com.andrewlalis.perfin.PerfinApp.helpRouter;
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A component that renders markdown-ish text as a series of TextFlow elements,
|
* A component that renders markdown-ish text as a series of TextFlow elements,
|
||||||
|
@ -26,9 +27,15 @@ public class StyledText extends VBox {
|
||||||
private StringProperty text;
|
private StringProperty text;
|
||||||
private boolean initialized = false;
|
private boolean initialized = false;
|
||||||
|
|
||||||
|
public StyledText() {
|
||||||
|
getStyleClass().add("spacing-extra");
|
||||||
|
}
|
||||||
|
|
||||||
public final void setText(String value) {
|
public final void setText(String value) {
|
||||||
|
initialized = false;
|
||||||
if (value == null) value = "";
|
if (value == null) value = "";
|
||||||
textProperty().set(value);
|
textProperty().set(value);
|
||||||
|
layoutChildren(); // Re-render the underlying text.
|
||||||
}
|
}
|
||||||
|
|
||||||
public final String getText() {
|
public final String getText() {
|
||||||
|
@ -54,7 +61,6 @@ public class StyledText extends VBox {
|
||||||
String s = getText();
|
String s = getText();
|
||||||
getChildren().clear();
|
getChildren().clear();
|
||||||
getChildren().addAll(renderText(s));
|
getChildren().addAll(renderText(s));
|
||||||
getStyleClass().add("spacing-extra");
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}
|
}
|
||||||
super.layoutChildren();
|
super.layoutChildren();
|
||||||
|
@ -75,6 +81,7 @@ public class StyledText extends VBox {
|
||||||
idx = 0;
|
idx = 0;
|
||||||
currentRun.setLength(0);
|
currentRun.setLength(0);
|
||||||
currentParagraph = new TextFlow();
|
currentParagraph = new TextFlow();
|
||||||
|
currentParagraph.setStyle("-fx-text-fill: inherit;");
|
||||||
|
|
||||||
while (idx < text.length()) {
|
while (idx < text.length()) {
|
||||||
if (text.startsWith("**", idx)) {
|
if (text.startsWith("**", idx)) {
|
||||||
|
@ -112,6 +119,7 @@ public class StyledText extends VBox {
|
||||||
int endIdx = text.indexOf(marker, idx + marker.length());
|
int endIdx = text.indexOf(marker, idx + marker.length());
|
||||||
Text textItem = new Text(text.substring(idx + marker.length(), endIdx));
|
Text textItem = new Text(text.substring(idx + marker.length(), endIdx));
|
||||||
textItem.getStyleClass().add(styleClass);
|
textItem.getStyleClass().add(styleClass);
|
||||||
|
textItem.setStyle("-fx-text-fill: inherit;");
|
||||||
currentParagraph.getChildren().add(textItem);
|
currentParagraph.getChildren().add(textItem);
|
||||||
idx = endIdx + marker.length();
|
idx = endIdx + marker.length();
|
||||||
}
|
}
|
||||||
|
@ -135,6 +143,8 @@ public class StyledText extends VBox {
|
||||||
hyperlink.setOnAction(event -> PerfinApp.instance.getHostServices().showDocument(link));
|
hyperlink.setOnAction(event -> PerfinApp.instance.getHostServices().showDocument(link));
|
||||||
} else if (link.startsWith("help:")) {
|
} else if (link.startsWith("help:")) {
|
||||||
hyperlink.setOnAction(event -> helpRouter.navigate(link.substring(5).strip()));
|
hyperlink.setOnAction(event -> helpRouter.navigate(link.substring(5).strip()));
|
||||||
|
} else if (link.startsWith("app:")) {
|
||||||
|
hyperlink.setOnAction(event -> router.navigate(link.substring(4).strip()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hyperlink.setBorder(Border.EMPTY);
|
hyperlink.setBorder(Border.EMPTY);
|
||||||
|
@ -175,6 +185,7 @@ public class StyledText extends VBox {
|
||||||
if (!currentParagraph.getChildren().isEmpty()) {
|
if (!currentParagraph.getChildren().isEmpty()) {
|
||||||
flows.add(currentParagraph);
|
flows.add(currentParagraph);
|
||||||
currentParagraph = new TextFlow();
|
currentParagraph = new TextFlow();
|
||||||
|
currentParagraph.setStyle("-fx-text-fill: inherit;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
public class TransactionLineItemTile extends BorderPane {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TransactionLineItemTile.class);
|
||||||
|
|
||||||
|
private TransactionLineItemTile() {}
|
||||||
|
|
||||||
|
public static CompletableFuture<TransactionLineItemTile> build(TransactionLineItem item, ObservableValue<Currency> currencyValue, List<TransactionCategory> categoriesCache) {
|
||||||
|
TransactionLineItemTile tile = new TransactionLineItemTile();
|
||||||
|
tile.getStyleClass().addAll("std-spacing", "std-padding", "small-font");
|
||||||
|
tile.setStyle("-fx-background-color: -fx-theme-background-2;");
|
||||||
|
Function<String, Label> boldLabelMaker = s -> {
|
||||||
|
Label lbl = new Label(s);
|
||||||
|
lbl.getStyleClass().addAll("bold-text");
|
||||||
|
return lbl;
|
||||||
|
};
|
||||||
|
Label descriptionLabel = new Label(item.getDescription());
|
||||||
|
Label valuePerItemLabel = new Label();
|
||||||
|
valuePerItemLabel.getStyleClass().add("mono-font");
|
||||||
|
valuePerItemLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getValuePerItem(), currency)))
|
||||||
|
);
|
||||||
|
Label totalValueLabel = new Label();
|
||||||
|
totalValueLabel.getStyleClass().add("mono-font");
|
||||||
|
totalValueLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getTotalValue(), currency)))
|
||||||
|
);
|
||||||
|
Label quantityLabel = new Label(Integer.toString(item.getQuantity()));
|
||||||
|
quantityLabel.getStyleClass().add("mono-font");
|
||||||
|
PropertiesPane propertiesPane = new PropertiesPane(80);
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Description"), descriptionLabel,
|
||||||
|
boldLabelMaker.apply("Quantity"), quantityLabel,
|
||||||
|
boldLabelMaker.apply("Item Value"), valuePerItemLabel,
|
||||||
|
boldLabelMaker.apply("Total"), totalValueLabel
|
||||||
|
);
|
||||||
|
tile.setCenter(propertiesPane);
|
||||||
|
if (item.getCategoryId() != null) {
|
||||||
|
if (categoriesCache != null) {
|
||||||
|
TransactionCategory category = categoriesCache.stream()
|
||||||
|
.filter(c -> c.id == item.getCategoryId())
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
log.warn("Failed to find cached category for line item.");
|
||||||
|
} else {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
} else {
|
||||||
|
CompletableFuture<TransactionLineItemTile> cf = new CompletableFuture<>();
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(item.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> Platform.runLater(() -> {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
cf.complete(tile);
|
||||||
|
}));
|
||||||
|
return cf;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -16,11 +16,10 @@ import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
import javafx.scene.text.Text;
|
import javafx.scene.text.Text;
|
||||||
import javafx.scene.text.TextFlow;
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,6 +34,7 @@ public class TransactionTile extends BorderPane {
|
||||||
setTop(getHeader(transaction));
|
setTop(getHeader(transaction));
|
||||||
setCenter(getBody(transaction));
|
setCenter(getBody(transaction));
|
||||||
setBottom(getFooter(transaction));
|
setBottom(getFooter(transaction));
|
||||||
|
setRight(getExtra(transaction));
|
||||||
|
|
||||||
selected.addListener((observable, oldValue, newValue) -> {
|
selected.addListener((observable, oldValue, newValue) -> {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
|
@ -56,12 +56,13 @@ public class TransactionTile extends BorderPane {
|
||||||
Label amountLabel = new Label("Amount");
|
Label amountLabel = new Label("Amount");
|
||||||
amountLabel.getStyleClass().add("bold-text");
|
amountLabel.getStyleClass().add("bold-text");
|
||||||
Label amountValue = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(transaction.getMoneyAmount()));
|
Label amountValue = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(transaction.getMoneyAmount()));
|
||||||
amountValue.getStyleClass().add("mono-font");
|
amountValue.getStyleClass().addAll("mono-font");
|
||||||
|
|
||||||
Label descriptionLabel = new Label("Description");
|
Label descriptionLabel = new Label("Description");
|
||||||
descriptionLabel.getStyleClass().add("bold-text");
|
descriptionLabel.getStyleClass().addAll("bold-text");
|
||||||
Label descriptionValue = new Label(transaction.getDescription());
|
Label descriptionValue = new Label(transaction.getDescription());
|
||||||
descriptionValue.setWrapText(true);
|
descriptionValue.setWrapText(true);
|
||||||
|
descriptionValue.setMaxWidth(500.0);
|
||||||
|
|
||||||
propertiesPane.getChildren().addAll(
|
propertiesPane.getChildren().addAll(
|
||||||
amountLabel, amountValue,
|
amountLabel, amountValue,
|
||||||
|
@ -71,7 +72,10 @@ public class TransactionTile extends BorderPane {
|
||||||
VBox bodyVBox = new VBox(
|
VBox bodyVBox = new VBox(
|
||||||
propertiesPane
|
propertiesPane
|
||||||
);
|
);
|
||||||
getCreditAndDebitAccounts(transaction).thenAccept(accounts -> {
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.findLinkedAccounts(transaction.id)
|
||||||
|
).thenAccept(accounts -> {
|
||||||
accounts.ifCredit(acc -> {
|
accounts.ifCredit(acc -> {
|
||||||
Hyperlink link = new Hyperlink(acc.getShortName());
|
Hyperlink link = new Hyperlink(acc.getShortName());
|
||||||
link.setOnAction(event -> router.navigate("account", acc));
|
link.setOnAction(event -> router.navigate("account", acc));
|
||||||
|
@ -99,10 +103,28 @@ public class TransactionTile extends BorderPane {
|
||||||
return footerHBox;
|
return footerHBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
|
private Node getExtra(Transaction transaction) {
|
||||||
return Profile.getCurrent().getDataSource().mapRepoAsync(
|
VBox categoryContainer = new VBox();
|
||||||
TransactionRepository.class,
|
VBox vendorContainer = new VBox();
|
||||||
repo -> repo.findLinkedAccounts(transaction.id)
|
VBox content = new VBox(categoryContainer, vendorContainer);
|
||||||
);
|
if (transaction.getCategoryId() != null) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(transaction.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> {
|
||||||
|
if (category == null) return;
|
||||||
|
Platform.runLater(() -> categoryContainer.getChildren().add(new CategoryLabel(category)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (transaction.getVendorId() != null) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionVendorRepository.class,
|
||||||
|
repo -> repo.findById(transaction.getVendorId()).orElse(null)
|
||||||
|
).thenAccept(vendor -> {
|
||||||
|
if (vendor == null) return;
|
||||||
|
Platform.runLater(() -> vendorContainer.getChildren().add(new Text("@ " + vendor.getName())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.control.Popups;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class VendorTile extends BorderPane {
|
||||||
|
public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
|
||||||
|
this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
|
||||||
|
this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
|
||||||
|
|
||||||
|
Label nameLabel = new Label(vendor.getName());
|
||||||
|
nameLabel.getStyleClass().addAll("bold-text");
|
||||||
|
Label descriptionLabel = new Label(vendor.getDescription());
|
||||||
|
descriptionLabel.setWrapText(true);
|
||||||
|
VBox contentVBox = new VBox(nameLabel, descriptionLabel);
|
||||||
|
contentVBox.getStyleClass().addAll("std-spacing");
|
||||||
|
this.setCenter(contentVBox);
|
||||||
|
BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
|
||||||
|
|
||||||
|
this.setRight(getRemoveButton(vendor, vendorRefresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setOnAction(event -> {
|
||||||
|
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone.");
|
||||||
|
if (confirm) {
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionVendorRepository.class,
|
||||||
|
repo -> repo.deleteById(transactionVendor.id)
|
||||||
|
);
|
||||||
|
vendorRefresh.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return removeButton;
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue