Refactored database schema for more flexible attachments and content, and added basis for account history.
This commit is contained in:
parent
aa90f98424
commit
00636debf3
5
pom.xml
5
pom.xml
|
@ -49,6 +49,11 @@
|
|||
<artifactId>h2</artifactId>
|
||||
<version>2.2.224</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.f4b6a3</groupId>
|
||||
<artifactId>ulid-creator</artifactId>
|
||||
<version>5.2.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -2,8 +2,9 @@ package com.andrewlalis.perfin.control;
|
|||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.data.FileUtil;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import javafx.application.Platform;
|
||||
|
@ -17,14 +18,15 @@ import javafx.scene.layout.VBox;
|
|||
import javafx.stage.FileChooser;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
@ -129,36 +131,21 @@ public class CreateTransactionController implements RouteSelectionListener {
|
|||
);
|
||||
alert.show();
|
||||
} else {
|
||||
LocalDateTime timestamp = parseTimestamp();
|
||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||
Currency currency = currencyChoiceBox.getValue();
|
||||
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
||||
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
|
||||
List<TransactionAttachment> attachments = selectedAttachmentFiles.stream()
|
||||
.map(file -> {
|
||||
String filename = file.getName();
|
||||
String filetypeSuffix = filename.substring(filename.lastIndexOf('.'));
|
||||
String mimeType = FileUtil.MIMETYPES.get(filetypeSuffix);
|
||||
return new TransactionAttachment(filename, mimeType);
|
||||
}).toList();
|
||||
Transaction transaction = new Transaction(timestamp, amount, currency, description);
|
||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||
List<Path> attachments = selectedAttachmentFiles.stream().map(File::toPath).toList();
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
long txId = repo.insert(transaction, affectedAccounts);
|
||||
repo.addAttachments(txId, attachments);
|
||||
// Copy the actual attachment files to their new locations.
|
||||
for (var attachment : repo.findAttachments(txId)) {
|
||||
Path filePath = attachment.getPath();
|
||||
Path dirPath = filePath.getParent();
|
||||
Path originalFilePath = selectedAttachmentFiles.stream()
|
||||
.filter(file -> file.getName().equals(attachment.getFilename()))
|
||||
.findFirst().orElseThrow().toPath();
|
||||
try {
|
||||
Files.createDirectories(dirPath);
|
||||
Files.copy(originalFilePath, filePath);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
repo.insert(
|
||||
utcTimestamp,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
linkedAccounts,
|
||||
attachments
|
||||
);
|
||||
});
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
|
@ -191,13 +178,11 @@ public class CreateTransactionController implements RouteSelectionListener {
|
|||
});
|
||||
}
|
||||
|
||||
private Map<Long, AccountEntry.Type> getSelectedAccounts() {
|
||||
Account debitAccount = linkDebitAccountComboBox.getValue();
|
||||
Account creditAccount = linkCreditAccountComboBox.getValue();
|
||||
Map<Long, AccountEntry.Type> accountsMap = new HashMap<>();
|
||||
if (debitAccount != null) accountsMap.put(debitAccount.getId(), AccountEntry.Type.DEBIT);
|
||||
if (creditAccount != null) accountsMap.put(creditAccount.getId(), AccountEntry.Type.CREDIT);
|
||||
return accountsMap;
|
||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||
return new CreditAndDebitAccounts(
|
||||
linkCreditAccountComboBox.getValue(),
|
||||
linkDebitAccountComboBox.getValue()
|
||||
);
|
||||
}
|
||||
|
||||
private LocalDateTime parseTimestamp() {
|
||||
|
|
|
@ -3,7 +3,6 @@ package com.andrewlalis.perfin.control;
|
|||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
|
@ -12,6 +11,9 @@ import javafx.scene.control.*;
|
|||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
|
@ -84,6 +86,7 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
AccountType type = accountTypeChoiceBox.getValue();
|
||||
Currency currency = accountCurrencyComboBox.getValue();
|
||||
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
||||
List<Path> attachments = Collections.emptyList();
|
||||
|
||||
Alert confirm = new Alert(
|
||||
Alert.AlertType.CONFIRMATION,
|
||||
|
@ -92,14 +95,13 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
Optional<ButtonType> result = confirm.showAndWait();
|
||||
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
|
||||
if (success) {
|
||||
Account newAccount = new Account(type, number, name, currency);
|
||||
long id = accountRepo.insert(newAccount);
|
||||
Account savedAccount = accountRepo.findById(id).orElseThrow();
|
||||
balanceRepo.insert(new BalanceRecord(id, initialBalance, savedAccount.getCurrency()));
|
||||
long id = accountRepo.insert(type, number, name, currency);
|
||||
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
|
||||
|
||||
// Once we create the new account, go to the account.
|
||||
Account newAccount = accountRepo.findById(id).orElseThrow();
|
||||
router.getHistory().clear();
|
||||
router.navigate("account", savedAccount);
|
||||
router.navigate("account", newAccount);
|
||||
}
|
||||
} else {
|
||||
System.out.println("Updating account " + account.getName());
|
||||
|
@ -113,7 +115,7 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
router.navigate("account", updatedAccount);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,10 +2,10 @@ package com.andrewlalis.perfin.control;
|
|||
|
||||
import com.andrewlalis.perfin.data.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.model.TransactionAttachment;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.AttachmentPreview;
|
||||
import javafx.application.Platform;
|
||||
|
@ -36,7 +36,7 @@ public class TransactionViewController {
|
|||
|
||||
@FXML public VBox attachmentsContainer;
|
||||
@FXML public HBox attachmentsHBox;
|
||||
private final ObservableList<TransactionAttachment> attachmentsList = FXCollections.observableArrayList();
|
||||
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||
|
||||
public void setTransaction(Transaction transaction) {
|
||||
this.transaction = transaction;
|
||||
|
@ -67,7 +67,7 @@ public class TransactionViewController {
|
|||
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
|
||||
Thread.ofVirtual().start(() -> {
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
List<TransactionAttachment> attachments = repo.findAttachments(transaction.getId());
|
||||
List<Attachment> attachments = repo.findAttachments(transaction.getId());
|
||||
Platform.runLater(() -> attachmentsList.setAll(attachments));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,8 +2,19 @@ package com.andrewlalis.perfin.data;
|
|||
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
|
||||
public interface AccountEntryRepository extends AutoCloseable {
|
||||
long insert(
|
||||
LocalDateTime timestamp,
|
||||
long accountId,
|
||||
long transactionId,
|
||||
BigDecimal amount,
|
||||
AccountEntry.Type type,
|
||||
Currency currency
|
||||
);
|
||||
List<AccountEntry> findAllByAccountId(long accountId);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface AccountHistoryItemRepository extends 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);
|
||||
}
|
|
@ -3,6 +3,7 @@ package com.andrewlalis.perfin.data;
|
|||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
|
@ -13,7 +14,7 @@ import java.util.Optional;
|
|||
import java.util.Set;
|
||||
|
||||
public interface AccountRepository extends AutoCloseable {
|
||||
long insert(Account account);
|
||||
long insert(AccountType type, String accountNumber, String name, Currency currency);
|
||||
Page<Account> findAll(PageRequest pagination);
|
||||
List<Account> findAllByCurrency(Currency currency);
|
||||
Optional<Account> findById(long id);
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AttachmentRepository extends AutoCloseable {
|
||||
Attachment insert(Path sourcePath);
|
||||
Optional<Attachment> findById(long attachmentId);
|
||||
Optional<Attachment> findByIdentifier(String identifier);
|
||||
void deleteById(long attachmentId);
|
||||
}
|
|
@ -2,7 +2,13 @@ package com.andrewlalis.perfin.data;
|
|||
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
|
||||
public interface BalanceRecordRepository extends AutoCloseable {
|
||||
long insert(BalanceRecord record);
|
||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||
BalanceRecord findLatestByAccountId(long accountId);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.model.Account;
|
|||
import javafx.application.Platform;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Currency;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
@ -12,6 +13,8 @@ import java.util.Map;
|
|||
import java.util.function.Consumer;
|
||||
|
||||
public interface DataSource {
|
||||
Path getContentDir();
|
||||
|
||||
AccountRepository getAccountRepository();
|
||||
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
|
||||
DbUtil.useClosable(this::getAccountRepository, repoConsumer);
|
||||
|
@ -27,6 +30,13 @@ public interface DataSource {
|
|||
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
|
||||
}
|
||||
|
||||
AttachmentRepository getAttachmentRepository();
|
||||
default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) {
|
||||
DbUtil.useClosable(this::getAttachmentRepository, repoConsumer);
|
||||
}
|
||||
|
||||
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
||||
|
||||
// Utility methods:
|
||||
|
||||
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
|
||||
|
|
|
@ -17,4 +17,16 @@ public class DateUtil {
|
|||
.atZoneSameInstant(ZoneId.systemDefault())
|
||||
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
|
||||
}
|
||||
|
||||
public static LocalDateTime localToUTC(LocalDateTime localTime, ZoneId localZone) {
|
||||
return localTime.atZone(localZone).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
|
||||
}
|
||||
|
||||
public static LocalDateTime localToUTC(LocalDateTime localTime) {
|
||||
return localToUTC(localTime, ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
public static LocalDateTime nowAsUTC() {
|
||||
return LocalDateTime.now(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,10 @@ public final class DbUtil {
|
|||
}
|
||||
}
|
||||
|
||||
public static void setArgs(PreparedStatement stmt, Object... args) {
|
||||
setArgs(stmt, List.of(args));
|
||||
}
|
||||
|
||||
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
|
|
|
@ -44,4 +44,14 @@ public class FileUtil {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static String getTypeSuffix(String filename) {
|
||||
int lastDotIdx = filename.lastIndexOf('.');
|
||||
if (lastDotIdx == -1) return "";
|
||||
return filename.substring(lastDotIdx);
|
||||
}
|
||||
|
||||
public static String getTypeSuffix(Path filePath) {
|
||||
return getTypeSuffix(filePath.getFileName().toString());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,20 +2,30 @@ package com.andrewlalis.perfin.data;
|
|||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public interface TransactionRepository extends AutoCloseable {
|
||||
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
|
||||
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
|
||||
long insert(
|
||||
LocalDateTime utcTimestamp,
|
||||
BigDecimal amount,
|
||||
Currency currency,
|
||||
String description,
|
||||
CreditAndDebitAccounts linkedAccounts,
|
||||
List<Path> attachments
|
||||
);
|
||||
Page<Transaction> findAll(PageRequest pagination);
|
||||
long countAll();
|
||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
|
||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||
List<TransactionAttachment> findAttachments(long transactionId);
|
||||
List<Attachment> findAttachments(long transactionId);
|
||||
void delete(long transactionId);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,41 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
|
||||
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
||||
@Override
|
||||
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
|
||||
long entryId = DbUtil.insertOne(
|
||||
conn,
|
||||
"""
|
||||
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcLDT(timestamp),
|
||||
accountId,
|
||||
transactionId,
|
||||
amount,
|
||||
type.name(),
|
||||
currency.getCurrencyCode()
|
||||
)
|
||||
);
|
||||
// Insert an entry into the account's history.
|
||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordAccountEntry(timestamp, accountId, entryId);
|
||||
return entryId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AccountEntry> findAllByAccountId(long accountId) {
|
||||
return DbUtil.findAll(
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
|
||||
|
||||
import java.sql.Connection;
|
||||
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_account_entry (item_id, description) VALUES (?, ?)",
|
||||
List.of(itemId, text)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
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,6 +1,7 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
|
@ -19,18 +20,24 @@ import java.util.*;
|
|||
|
||||
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||
@Override
|
||||
public long insert(Account account) {
|
||||
return DbUtil.insertOne(
|
||||
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
|
||||
return DbUtil.doTransaction(conn, () -> {
|
||||
long accountId = DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcNow(),
|
||||
account.getType().name(),
|
||||
account.getAccountNumber(),
|
||||
account.getName(),
|
||||
account.getCurrency().getCurrencyCode()
|
||||
type.name(),
|
||||
accountNumber,
|
||||
name,
|
||||
currency.getCurrencyCode()
|
||||
)
|
||||
);
|
||||
// Insert a history item indicating the creation of the account.
|
||||
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
|
||||
return accountId;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.data.FileUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.github.f4b6a3.ulid.UlidCreator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public record JdbcAttachmentRepository(Connection conn, Path contentDir) implements AttachmentRepository {
|
||||
@Override
|
||||
public Attachment insert(Path sourcePath) {
|
||||
String filename = sourcePath.getFileName().toString();
|
||||
String filetypeSuffix = FileUtil.getTypeSuffix(filename).toLowerCase();
|
||||
String contentType = FileUtil.MIMETYPES.getOrDefault(filetypeSuffix, "text/plain");
|
||||
Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||
String identifier = UlidCreator.getUlid().toString();
|
||||
long id = DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO attachment (uploaded_at, identifier, filename, content_type) VALUES (?, ?, ?, ?)",
|
||||
List.of(timestamp, identifier, filename, contentType)
|
||||
);
|
||||
Attachment attachment = new Attachment(id, DbUtil.utcLDTFromTimestamp(timestamp), identifier, filename, contentType);
|
||||
// Save the file to the content directory.
|
||||
Path storageFilePath = attachment.getPath(contentDir);
|
||||
try {
|
||||
Files.createDirectories(storageFilePath.getParent());
|
||||
Files.copy(sourcePath, storageFilePath);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Attachment> findById(long attachmentId) {
|
||||
return DbUtil.findOne(conn, "SELECT * FROM attachment WHERE id = ?", List.of(attachmentId), JdbcAttachmentRepository::parseAttachment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Attachment> findByIdentifier(String identifier) {
|
||||
return DbUtil.findOne(conn, "SELECT * FROM attachment WHERE identifier = ?", List.of(identifier), JdbcAttachmentRepository::parseAttachment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(long attachmentId) {
|
||||
// First get it and try to delete the stored file.
|
||||
var optionalAttachment = findById(attachmentId);
|
||||
if (optionalAttachment.isPresent()) {
|
||||
try {
|
||||
Files.delete(optionalAttachment.get().getPath(contentDir));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace(System.err);
|
||||
// TODO: Add some sort of persistent error logging.
|
||||
}
|
||||
DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
public static Attachment parseAttachment(ResultSet rs) throws SQLException {
|
||||
return new Attachment(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("uploaded_at")),
|
||||
rs.getString("identifier"),
|
||||
rs.getString("filename"),
|
||||
rs.getString("content_type")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,28 +1,44 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
|
||||
public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRecordRepository {
|
||||
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||
@Override
|
||||
public long insert(BalanceRecord record) {
|
||||
return DbUtil.insertOne(
|
||||
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
|
||||
return DbUtil.doTransaction(conn, () -> {
|
||||
long recordId = DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcNow(),
|
||||
record.getAccountId(),
|
||||
record.getBalance(),
|
||||
record.getCurrency().getCurrencyCode()
|
||||
)
|
||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency)
|
||||
);
|
||||
// Insert attachments.
|
||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||
try (var stmt = conn.prepareStatement("INSERT INTO balance_record_attachment(balance_record_id, attachment_id) VALUES (?, ?)")) {
|
||||
for (var attachmentPath : attachments) {
|
||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||
DbUtil.setArgs(stmt, recordId, attachment.getId());
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
// Add a history item entry.
|
||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
|
||||
return recordId;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl;
|
|||
|
||||
import com.andrewlalis.perfin.data.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
@ -12,9 +13,11 @@ import java.sql.SQLException;
|
|||
*/
|
||||
public class JdbcDataSource implements DataSource {
|
||||
private final String jdbcUrl;
|
||||
private final Path contentDir;
|
||||
|
||||
public JdbcDataSource(String jdbcUrl) {
|
||||
public JdbcDataSource(String jdbcUrl, Path contentDir) {
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
this.contentDir = contentDir;
|
||||
}
|
||||
|
||||
public Connection getConnection() {
|
||||
|
@ -25,6 +28,11 @@ public class JdbcDataSource implements DataSource {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getContentDir() {
|
||||
return contentDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountRepository getAccountRepository() {
|
||||
return new JdbcAccountRepository(getConnection());
|
||||
|
@ -32,11 +40,21 @@ public class JdbcDataSource implements DataSource {
|
|||
|
||||
@Override
|
||||
public BalanceRecordRepository getBalanceRecordRepository() {
|
||||
return new JdbcBalanceRecordRepository(getConnection());
|
||||
return new JdbcBalanceRecordRepository(getConnection(), contentDir);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionRepository getTransactionRepository() {
|
||||
return new JdbcTransactionRepository(getConnection());
|
||||
return new JdbcTransactionRepository(getConnection(), contentDir);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttachmentRepository getAttachmentRepository() {
|
||||
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountHistoryItemRepository getAccountHistoryItemRepository() {
|
||||
return new JdbcAccountHistoryItemRepository(getConnection());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,84 +1,58 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
|
||||
public record JdbcTransactionRepository(Connection conn, Path contentDir) implements TransactionRepository {
|
||||
@Override
|
||||
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
|
||||
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||
public long insert(
|
||||
LocalDateTime utcTimestamp,
|
||||
BigDecimal amount,
|
||||
Currency currency,
|
||||
String description,
|
||||
CreditAndDebitAccounts linkedAccounts,
|
||||
List<Path> attachments
|
||||
) {
|
||||
return DbUtil.doTransaction(conn, () -> {
|
||||
long txId = insertTransaction(timestamp, transaction);
|
||||
insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap);
|
||||
return txId;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addAttachments(long transactionId, List<TransactionAttachment> attachments) {
|
||||
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
for (var attachment : attachments) {
|
||||
DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO transaction_attachment (uploaded_at, transaction_id, filename, content_type) VALUES (?, ?, ?, ?)",
|
||||
List.of(
|
||||
timestamp,
|
||||
transactionId,
|
||||
attachment.getFilename(),
|
||||
attachment.getContentType()
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private long insertTransaction(Timestamp timestamp, Transaction transaction) {
|
||||
return DbUtil.insertOne(
|
||||
// 1. Insert the transaction.
|
||||
long txId = DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
||||
List.of(
|
||||
timestamp,
|
||||
transaction.getAmount(),
|
||||
transaction.getCurrency().getCurrencyCode(),
|
||||
transaction.getDescription()
|
||||
)
|
||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
|
||||
);
|
||||
}
|
||||
|
||||
private void insertAccountEntriesForTransaction(
|
||||
Timestamp timestamp,
|
||||
long txId,
|
||||
Transaction transaction,
|
||||
Map<Long, AccountEntry.Type> accountsMap
|
||||
) throws SQLException {
|
||||
try (var stmt = conn.prepareStatement(
|
||||
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)) {
|
||||
for (var entry : accountsMap.entrySet()) {
|
||||
long accountId = entry.getKey();
|
||||
AccountEntry.Type entryType = entry.getValue();
|
||||
DbUtil.setArgs(stmt, List.of(
|
||||
timestamp,
|
||||
accountId,
|
||||
txId,
|
||||
transaction.getAmount(),
|
||||
entryType.name(),
|
||||
transaction.getCurrency().getCurrencyCode()
|
||||
));
|
||||
// 2. Insert linked account entries.
|
||||
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
||||
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.DEBIT, currency));
|
||||
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.CREDIT, currency));
|
||||
// 3. Add attachments.
|
||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
|
||||
for (var attachmentPath : attachments) {
|
||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||
// Insert the link-table entry.
|
||||
DbUtil.setArgs(stmt, txId, attachment.getId());
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
return txId;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -87,7 +61,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
conn,
|
||||
"SELECT * FROM transaction",
|
||||
pagination,
|
||||
JdbcTransactionRepository::parse
|
||||
JdbcTransactionRepository::parseTransaction
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -105,28 +79,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id
|
||||
WHERE account_entry.account_id IN (%s)
|
||||
""", idsStr);
|
||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId) {
|
||||
List<AccountEntry> entries = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE transaction_id = ?",
|
||||
List.of(transactionId),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
Map<AccountEntry, Account> map = new HashMap<>();
|
||||
for (var entry : entries) {
|
||||
Account account = DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM account WHERE id = ?",
|
||||
List.of(entry.getAccountId()),
|
||||
JdbcAccountRepository::parseAccount
|
||||
).orElseThrow();
|
||||
map.put(entry, account);
|
||||
}
|
||||
return map;
|
||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -157,12 +110,17 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
}
|
||||
|
||||
@Override
|
||||
public List<TransactionAttachment> findAttachments(long transactionId) {
|
||||
public List<Attachment> findAttachments(long transactionId) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM transaction_attachment WHERE transaction_id = ? ORDER BY filename ASC",
|
||||
"""
|
||||
SELECT *
|
||||
FROM attachment
|
||||
LEFT JOIN transaction_attachment ta ON ta.attachment_id = attachment.id
|
||||
WHERE ta.transaction_id = ?
|
||||
ORDER BY uploaded_at ASC, filename ASC""",
|
||||
List.of(transactionId),
|
||||
JdbcTransactionRepository::parseAttachment
|
||||
JdbcAttachmentRepository::parseAttachment
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -176,7 +134,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
conn.close();
|
||||
}
|
||||
|
||||
public static Transaction parse(ResultSet rs) throws SQLException {
|
||||
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
||||
return new Transaction(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
|
@ -185,14 +143,4 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
|||
rs.getString("description")
|
||||
);
|
||||
}
|
||||
|
||||
public static TransactionAttachment parseAttachment(ResultSet rs) throws SQLException {
|
||||
return new TransactionAttachment(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("uploaded_at")),
|
||||
rs.getLong("transaction_id"),
|
||||
rs.getString("filename"),
|
||||
rs.getString("content_type")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import com.andrewlalis.perfin.data.FileUtil;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* An attachment is a file uploaded so that it may be related to some other
|
||||
* entity, like a receipt attached to a transaction, or a bank statement to an
|
||||
* account balance record.
|
||||
*/
|
||||
public class Attachment {
|
||||
private long id;
|
||||
private LocalDateTime timestamp;
|
||||
private String identifier;
|
||||
private String filename;
|
||||
private String contentType;
|
||||
|
||||
public Attachment(long id, LocalDateTime timestamp, String identifier, String filename, String contentType) {
|
||||
this.id = id;
|
||||
this.timestamp = timestamp;
|
||||
this.identifier = identifier;
|
||||
this.filename = filename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public Attachment(LocalDateTime timestamp, String identifier, String filename, String contentType) {
|
||||
this.timestamp = timestamp;
|
||||
this.identifier = identifier;
|
||||
this.filename = filename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public Path getPath(Path contentDir) {
|
||||
return contentDir.resolve(timestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
|
||||
.resolve(identifier + FileUtil.getTypeSuffix(filename).toLowerCase());
|
||||
}
|
||||
}
|
|
@ -148,7 +148,7 @@ public class Profile {
|
|||
String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
|
||||
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
|
||||
boolean exists = Files.exists(getDatabaseFile(name));
|
||||
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl);
|
||||
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl, getContentDir(name));
|
||||
if (!exists) {// Initialize the datasource using schema.sql.
|
||||
try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
|
||||
if (in == null) throw new IOException("Could not load /sql/schema.sql");
|
||||
|
|
|
@ -9,6 +9,7 @@ import java.time.LocalDateTime;
|
|||
* A file that's been attached to a transaction as additional context for it,
|
||||
* like a receipt or invoice copy.
|
||||
*/
|
||||
@Deprecated
|
||||
public class TransactionAttachment {
|
||||
private long id;
|
||||
private LocalDateTime uploadedAt;
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
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 {
|
||||
private long id;
|
||||
private LocalDateTime timestamp;
|
||||
private long accountId;
|
||||
private AccountHistoryItemType type;
|
||||
|
||||
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
||||
this.id = id;
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public AccountHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public AccountHistoryItemType getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
public enum AccountHistoryItemType {
|
||||
TEXT,
|
||||
ACCOUNT_ENTRY,
|
||||
BALANCE_RECORD
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.model.TransactionAttachment;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
|
@ -20,7 +21,7 @@ public class AttachmentPreview extends BorderPane {
|
|||
public static final double LABEL_SIZE = 18.0;
|
||||
public static final double HEIGHT = IMAGE_SIZE + LABEL_SIZE;
|
||||
|
||||
public AttachmentPreview(TransactionAttachment attachment) {
|
||||
public AttachmentPreview(Attachment attachment) {
|
||||
BorderPane contentContainer = new BorderPane();
|
||||
Label nameLabel = new Label(attachment.getFilename());
|
||||
nameLabel.setStyle("-fx-font-size: small;");
|
||||
|
@ -33,7 +34,7 @@ public class AttachmentPreview extends BorderPane {
|
|||
boolean showDocIcon = true;
|
||||
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
|
||||
if (imageTypes.contains(attachment.getContentType())) {
|
||||
try (var in = Files.newInputStream(attachment.getPath())) {
|
||||
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
|
||||
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
||||
contentContainer.setCenter(new ImageView(img));
|
||||
showDocIcon = false;
|
||||
|
@ -43,6 +44,7 @@ public class AttachmentPreview extends BorderPane {
|
|||
}
|
||||
if (showDocIcon) {
|
||||
try (var in = AttachmentPreview.class.getResourceAsStream("/images/doc-icon.png")) {
|
||||
if (in == null) throw new NullPointerException("Missing /images/doc-icon.png resource.");
|
||||
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
||||
contentContainer.setCenter(new ImageView(img));
|
||||
} catch (IOException e) {
|
||||
|
|
|
@ -9,6 +9,8 @@ module com.andrewlalis.perfin {
|
|||
|
||||
requires java.sql;
|
||||
|
||||
requires com.github.f4b6a3.ulid;
|
||||
|
||||
exports com.andrewlalis.perfin to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.view to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.model to javafx.graphics;
|
||||
|
|
|
@ -16,6 +16,14 @@ CREATE TABLE transaction (
|
|||
description VARCHAR(255) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE attachment (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
uploaded_at TIMESTAMP NOT NULL,
|
||||
identifier VARCHAR(63) NOT NULL UNIQUE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE account_entry (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
|
@ -33,16 +41,15 @@ CREATE TABLE account_entry (
|
|||
);
|
||||
|
||||
CREATE TABLE transaction_attachment (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
uploaded_at TIMESTAMP NOT NULL,
|
||||
transaction_id BIGINT NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(255) NOT NULL,
|
||||
attachment_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, attachment_id),
|
||||
CONSTRAINT fk_transaction_attachment_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT uq_transaction_attachment_filename
|
||||
UNIQUE(transaction_id, filename)
|
||||
CONSTRAINT fk_transaction_attachment_attachment
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE balance_record (
|
||||
|
@ -55,3 +62,55 @@ CREATE TABLE balance_record (
|
|||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE balance_record_attachment (
|
||||
balance_record_id BIGINT NOT NULL,
|
||||
attachment_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (balance_record_id, attachment_id),
|
||||
CONSTRAINT fk_balance_record_attachment_balance_record
|
||||
FOREIGN KEY (balance_record_id) REFERENCES balance_record(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_balance_record_attachment_attachment
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
account_id BIGINT NOT NULL,
|
||||
type VARCHAR(63) NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_text (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_text_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_account_entry (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
entry_id BIGINT NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_account_entry_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_history_item_account_entry
|
||||
FOREIGN KEY (entry_id) REFERENCES account_entry(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_balance_record (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
record_id BIGINT NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_balance_record_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_history_item_balance_record
|
||||
FOREIGN KEY (record_id) REFERENCES balance_record(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
|
|
@ -4,25 +4,6 @@ It contains all the files and other large content that you've added to your
|
|||
profile, including but not limited to transaction attachments (receipts,
|
||||
invoices, etc.), bank statements, or portfolio exports. These files are usually
|
||||
managed by the Perfin app through in-app actions, but you're also welcome to
|
||||
browse them directly, or even delete files you no longer want stored.
|
||||
|
||||
Here's an overview of where you can find everything:
|
||||
|
||||
- transaction-attachments/
|
||||
This folder contains all files you've attached to transactions you've created.
|
||||
Within this folder, you'll see a series of sub-folders organized by the date
|
||||
at which attachments were uploaded, and in side each date folder, you'll find
|
||||
one folder for each transaction. For example, your folder might look like this:
|
||||
|
||||
my-profile/
|
||||
content/
|
||||
transaction-attachments/
|
||||
2023-12-28/
|
||||
tx-2/
|
||||
receipt.png
|
||||
tx-3/
|
||||
invoice.pdf
|
||||
2024-01-04/
|
||||
tx-4/
|
||||
receipt.jpeg
|
||||
|
||||
browse them directly, or even delete files you no longer want stored. The app
|
||||
will gracefully accept that they've been removed, and should carry on
|
||||
regardless.
|
||||
|
|
Loading…
Reference in New Issue