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>
|
<artifactId>h2</artifactId>
|
||||||
<version>2.2.224</version>
|
<version>2.2.224</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.f4b6a3</groupId>
|
||||||
|
<artifactId>ulid-creator</artifactId>
|
||||||
|
<version>5.2.2</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
@ -2,8 +2,9 @@ package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.DateUtil;
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.FileUtil;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.*;
|
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -17,14 +18,15 @@ import javafx.scene.layout.VBox;
|
||||||
import javafx.stage.FileChooser;
|
import javafx.stage.FileChooser;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
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.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
@ -129,36 +131,21 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
);
|
);
|
||||||
alert.show();
|
alert.show();
|
||||||
} else {
|
} else {
|
||||||
LocalDateTime timestamp = parseTimestamp();
|
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||||
Currency currency = currencyChoiceBox.getValue();
|
Currency currency = currencyChoiceBox.getValue();
|
||||||
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
||||||
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
|
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||||
List<TransactionAttachment> attachments = selectedAttachmentFiles.stream()
|
List<Path> attachments = selectedAttachmentFiles.stream().map(File::toPath).toList();
|
||||||
.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);
|
|
||||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||||
long txId = repo.insert(transaction, affectedAccounts);
|
repo.insert(
|
||||||
repo.addAttachments(txId, attachments);
|
utcTimestamp,
|
||||||
// Copy the actual attachment files to their new locations.
|
amount,
|
||||||
for (var attachment : repo.findAttachments(txId)) {
|
currency,
|
||||||
Path filePath = attachment.getPath();
|
description,
|
||||||
Path dirPath = filePath.getParent();
|
linkedAccounts,
|
||||||
Path originalFilePath = selectedAttachmentFiles.stream()
|
attachments
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
router.navigateBackAndClear();
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
|
@ -191,13 +178,11 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, AccountEntry.Type> getSelectedAccounts() {
|
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||||
Account debitAccount = linkDebitAccountComboBox.getValue();
|
return new CreditAndDebitAccounts(
|
||||||
Account creditAccount = linkCreditAccountComboBox.getValue();
|
linkCreditAccountComboBox.getValue(),
|
||||||
Map<Long, AccountEntry.Type> accountsMap = new HashMap<>();
|
linkDebitAccountComboBox.getValue()
|
||||||
if (debitAccount != null) accountsMap.put(debitAccount.getId(), AccountEntry.Type.DEBIT);
|
);
|
||||||
if (creditAccount != null) accountsMap.put(creditAccount.getId(), AccountEntry.Type.CREDIT);
|
|
||||||
return accountsMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime parseTimestamp() {
|
private LocalDateTime parseTimestamp() {
|
||||||
|
|
|
@ -3,7 +3,6 @@ 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.model.Account;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
@ -12,6 +11,9 @@ import javafx.scene.control.*;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@ -84,6 +86,7 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
AccountType type = accountTypeChoiceBox.getValue();
|
AccountType type = accountTypeChoiceBox.getValue();
|
||||||
Currency currency = accountCurrencyComboBox.getValue();
|
Currency currency = accountCurrencyComboBox.getValue();
|
||||||
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
||||||
|
List<Path> attachments = Collections.emptyList();
|
||||||
|
|
||||||
Alert confirm = new Alert(
|
Alert confirm = new Alert(
|
||||||
Alert.AlertType.CONFIRMATION,
|
Alert.AlertType.CONFIRMATION,
|
||||||
|
@ -92,14 +95,13 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
Optional<ButtonType> result = confirm.showAndWait();
|
Optional<ButtonType> result = confirm.showAndWait();
|
||||||
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
|
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
|
||||||
if (success) {
|
if (success) {
|
||||||
Account newAccount = new Account(type, number, name, currency);
|
long id = accountRepo.insert(type, number, name, currency);
|
||||||
long id = accountRepo.insert(newAccount);
|
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
|
||||||
Account savedAccount = accountRepo.findById(id).orElseThrow();
|
|
||||||
balanceRepo.insert(new BalanceRecord(id, initialBalance, savedAccount.getCurrency()));
|
|
||||||
|
|
||||||
// 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();
|
||||||
router.getHistory().clear();
|
router.getHistory().clear();
|
||||||
router.navigate("account", savedAccount);
|
router.navigate("account", newAccount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
System.out.println("Updating account " + account.getName());
|
System.out.println("Updating account " + account.getName());
|
||||||
|
@ -113,7 +115,7 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
router.navigate("account", updatedAccount);
|
router.navigate("account", updatedAccount);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} 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.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.DateUtil;
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||||
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.model.TransactionAttachment;
|
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AttachmentPreview;
|
import com.andrewlalis.perfin.view.component.AttachmentPreview;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -36,7 +36,7 @@ public class TransactionViewController {
|
||||||
|
|
||||||
@FXML public VBox attachmentsContainer;
|
@FXML public VBox attachmentsContainer;
|
||||||
@FXML public HBox attachmentsHBox;
|
@FXML public HBox attachmentsHBox;
|
||||||
private final ObservableList<TransactionAttachment> attachmentsList = FXCollections.observableArrayList();
|
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
public void setTransaction(Transaction transaction) {
|
public void setTransaction(Transaction transaction) {
|
||||||
this.transaction = transaction;
|
this.transaction = transaction;
|
||||||
|
@ -67,7 +67,7 @@ public class TransactionViewController {
|
||||||
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
|
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
|
||||||
Thread.ofVirtual().start(() -> {
|
Thread.ofVirtual().start(() -> {
|
||||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||||
List<TransactionAttachment> attachments = repo.findAttachments(transaction.getId());
|
List<Attachment> attachments = repo.findAttachments(transaction.getId());
|
||||||
Platform.runLater(() -> attachmentsList.setAll(attachments));
|
Platform.runLater(() -> attachmentsList.setAll(attachments));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,8 +2,19 @@ package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface AccountEntryRepository extends AutoCloseable {
|
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);
|
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.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 java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
@ -13,7 +14,7 @@ import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface AccountRepository extends AutoCloseable {
|
public interface AccountRepository extends AutoCloseable {
|
||||||
long insert(Account account);
|
long insert(AccountType type, String accountNumber, String name, Currency currency);
|
||||||
Page<Account> findAll(PageRequest pagination);
|
Page<Account> findAll(PageRequest pagination);
|
||||||
List<Account> findAllByCurrency(Currency currency);
|
List<Account> findAllByCurrency(Currency currency);
|
||||||
Optional<Account> findById(long id);
|
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 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 {
|
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);
|
BalanceRecord findLatestByAccountId(long accountId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.model.Account;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -12,6 +13,8 @@ import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public interface DataSource {
|
public interface DataSource {
|
||||||
|
Path getContentDir();
|
||||||
|
|
||||||
AccountRepository getAccountRepository();
|
AccountRepository getAccountRepository();
|
||||||
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
|
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
|
||||||
DbUtil.useClosable(this::getAccountRepository, repoConsumer);
|
DbUtil.useClosable(this::getAccountRepository, repoConsumer);
|
||||||
|
@ -27,6 +30,13 @@ public interface DataSource {
|
||||||
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
|
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AttachmentRepository getAttachmentRepository();
|
||||||
|
default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) {
|
||||||
|
DbUtil.useClosable(this::getAttachmentRepository, repoConsumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
||||||
|
|
||||||
// Utility methods:
|
// Utility methods:
|
||||||
|
|
||||||
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
|
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
|
||||||
|
|
|
@ -17,4 +17,16 @@ public class DateUtil {
|
||||||
.atZoneSameInstant(ZoneId.systemDefault())
|
.atZoneSameInstant(ZoneId.systemDefault())
|
||||||
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
|
.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) {
|
public static <T> List<T> findAll(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);
|
||||||
|
|
|
@ -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.Page;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
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.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface TransactionRepository extends AutoCloseable {
|
public interface TransactionRepository extends AutoCloseable {
|
||||||
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
|
long insert(
|
||||||
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
|
LocalDateTime utcTimestamp,
|
||||||
|
BigDecimal amount,
|
||||||
|
Currency currency,
|
||||||
|
String description,
|
||||||
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
List<Path> attachments
|
||||||
|
);
|
||||||
Page<Transaction> findAll(PageRequest pagination);
|
Page<Transaction> findAll(PageRequest pagination);
|
||||||
long countAll();
|
long countAll();
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
|
|
||||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||||
List<TransactionAttachment> findAttachments(long transactionId);
|
List<Attachment> findAttachments(long transactionId);
|
||||||
void delete(long transactionId);
|
void delete(long transactionId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,41 @@
|
||||||
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.DbUtil;
|
import com.andrewlalis.perfin.data.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
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
|
@Override
|
||||||
public List<AccountEntry> findAllByAccountId(long accountId) {
|
public List<AccountEntry> findAllByAccountId(long accountId) {
|
||||||
return DbUtil.findAll(
|
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;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.DbUtil;
|
import com.andrewlalis.perfin.data.DbUtil;
|
||||||
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;
|
||||||
|
@ -19,18 +20,24 @@ import java.util.*;
|
||||||
|
|
||||||
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(Account account) {
|
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
|
||||||
return DbUtil.insertOne(
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
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) VALUES (?, ?, ?, ?, ?)",
|
||||||
List.of(
|
List.of(
|
||||||
DbUtil.timestampFromUtcNow(),
|
DbUtil.timestampFromUtcNow(),
|
||||||
account.getType().name(),
|
type.name(),
|
||||||
account.getAccountNumber(),
|
accountNumber,
|
||||||
account.getName(),
|
name,
|
||||||
account.getCurrency().getCurrencyCode()
|
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
|
@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;
|
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.BalanceRecordRepository;
|
||||||
import com.andrewlalis.perfin.data.DbUtil;
|
import com.andrewlalis.perfin.data.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRecordRepository {
|
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(BalanceRecord record) {
|
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
|
||||||
return DbUtil.insertOne(
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
long recordId = DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
||||||
List.of(
|
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency)
|
||||||
DbUtil.timestampFromUtcNow(),
|
|
||||||
record.getAccountId(),
|
|
||||||
record.getBalance(),
|
|
||||||
record.getCurrency().getCurrencyCode()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
// 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
|
@Override
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.*;
|
import com.andrewlalis.perfin.data.*;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
@ -12,9 +13,11 @@ import java.sql.SQLException;
|
||||||
*/
|
*/
|
||||||
public class JdbcDataSource implements DataSource {
|
public class JdbcDataSource implements DataSource {
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
private final Path contentDir;
|
||||||
|
|
||||||
public JdbcDataSource(String jdbcUrl) {
|
public JdbcDataSource(String jdbcUrl, Path contentDir) {
|
||||||
this.jdbcUrl = jdbcUrl;
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
this.contentDir = contentDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Connection getConnection() {
|
public Connection getConnection() {
|
||||||
|
@ -25,6 +28,11 @@ public class JdbcDataSource implements DataSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path getContentDir() {
|
||||||
|
return contentDir;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AccountRepository getAccountRepository() {
|
public AccountRepository getAccountRepository() {
|
||||||
return new JdbcAccountRepository(getConnection());
|
return new JdbcAccountRepository(getConnection());
|
||||||
|
@ -32,11 +40,21 @@ public class JdbcDataSource implements DataSource {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BalanceRecordRepository getBalanceRecordRepository() {
|
public BalanceRecordRepository getBalanceRecordRepository() {
|
||||||
return new JdbcBalanceRecordRepository(getConnection());
|
return new JdbcBalanceRecordRepository(getConnection(), contentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TransactionRepository getTransactionRepository() {
|
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;
|
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.DbUtil;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
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.model.*;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.Collections;
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
|
public record JdbcTransactionRepository(Connection conn, Path contentDir) implements TransactionRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
|
public long insert(
|
||||||
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
LocalDateTime utcTimestamp,
|
||||||
|
BigDecimal amount,
|
||||||
|
Currency currency,
|
||||||
|
String description,
|
||||||
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
List<Path> attachments
|
||||||
|
) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
long txId = insertTransaction(timestamp, transaction);
|
// 1. Insert the transaction.
|
||||||
insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap);
|
long txId = DbUtil.insertOne(
|
||||||
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(
|
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
||||||
List.of(
|
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
|
||||||
timestamp,
|
|
||||||
transaction.getAmount(),
|
|
||||||
transaction.getCurrency().getCurrencyCode(),
|
|
||||||
transaction.getDescription()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
// 2. Insert linked account entries.
|
||||||
|
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
||||||
private void insertAccountEntriesForTransaction(
|
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.DEBIT, currency));
|
||||||
Timestamp timestamp,
|
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.CREDIT, currency));
|
||||||
long txId,
|
// 3. Add attachments.
|
||||||
Transaction transaction,
|
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
Map<Long, AccountEntry.Type> accountsMap
|
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
|
||||||
) throws SQLException {
|
for (var attachmentPath : attachments) {
|
||||||
try (var stmt = conn.prepareStatement(
|
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||||
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
|
// Insert the link-table entry.
|
||||||
)) {
|
DbUtil.setArgs(stmt, txId, attachment.getId());
|
||||||
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()
|
|
||||||
));
|
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return txId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -87,7 +61,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM transaction",
|
"SELECT * FROM transaction",
|
||||||
pagination,
|
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
|
LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id
|
||||||
WHERE account_entry.account_id IN (%s)
|
WHERE account_entry.account_id IN (%s)
|
||||||
""", idsStr);
|
""", idsStr);
|
||||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parse);
|
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -157,12 +110,17 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<TransactionAttachment> findAttachments(long transactionId) {
|
public List<Attachment> findAttachments(long transactionId) {
|
||||||
return DbUtil.findAll(
|
return DbUtil.findAll(
|
||||||
conn,
|
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),
|
List.of(transactionId),
|
||||||
JdbcTransactionRepository::parseAttachment
|
JdbcAttachmentRepository::parseAttachment
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,7 +134,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
conn.close();
|
conn.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Transaction parse(ResultSet rs) throws SQLException {
|
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
||||||
return new Transaction(
|
return new Transaction(
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||||
|
@ -185,14 +143,4 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
rs.getString("description")
|
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 databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
|
||||||
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
|
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
|
||||||
boolean exists = Files.exists(getDatabaseFile(name));
|
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.
|
if (!exists) {// Initialize the datasource using schema.sql.
|
||||||
try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
|
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");
|
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,
|
* A file that's been attached to a transaction as additional context for it,
|
||||||
* like a receipt or invoice copy.
|
* like a receipt or invoice copy.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public class TransactionAttachment {
|
public class TransactionAttachment {
|
||||||
private long id;
|
private long id;
|
||||||
private LocalDateTime uploadedAt;
|
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;
|
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.control.Label;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import javafx.scene.image.ImageView;
|
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 LABEL_SIZE = 18.0;
|
||||||
public static final double HEIGHT = IMAGE_SIZE + LABEL_SIZE;
|
public static final double HEIGHT = IMAGE_SIZE + LABEL_SIZE;
|
||||||
|
|
||||||
public AttachmentPreview(TransactionAttachment attachment) {
|
public AttachmentPreview(Attachment attachment) {
|
||||||
BorderPane contentContainer = new BorderPane();
|
BorderPane contentContainer = new BorderPane();
|
||||||
Label nameLabel = new Label(attachment.getFilename());
|
Label nameLabel = new Label(attachment.getFilename());
|
||||||
nameLabel.setStyle("-fx-font-size: small;");
|
nameLabel.setStyle("-fx-font-size: small;");
|
||||||
|
@ -33,7 +34,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())) {
|
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
|
||||||
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;
|
||||||
|
@ -43,6 +44,7 @@ public class AttachmentPreview extends BorderPane {
|
||||||
}
|
}
|
||||||
if (showDocIcon) {
|
if (showDocIcon) {
|
||||||
try (var in = AttachmentPreview.class.getResourceAsStream("/images/doc-icon.png")) {
|
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);
|
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
||||||
contentContainer.setCenter(new ImageView(img));
|
contentContainer.setCenter(new ImageView(img));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|
|
@ -9,6 +9,8 @@ module com.andrewlalis.perfin {
|
||||||
|
|
||||||
requires java.sql;
|
requires java.sql;
|
||||||
|
|
||||||
|
requires com.github.f4b6a3.ulid;
|
||||||
|
|
||||||
exports com.andrewlalis.perfin to javafx.graphics;
|
exports com.andrewlalis.perfin to javafx.graphics;
|
||||||
exports com.andrewlalis.perfin.view to javafx.graphics;
|
exports com.andrewlalis.perfin.view to javafx.graphics;
|
||||||
exports com.andrewlalis.perfin.model to javafx.graphics;
|
exports com.andrewlalis.perfin.model to javafx.graphics;
|
||||||
|
|
|
@ -16,6 +16,14 @@ CREATE TABLE transaction (
|
||||||
description VARCHAR(255) NULL
|
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 (
|
CREATE TABLE account_entry (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
timestamp TIMESTAMP NOT NULL,
|
timestamp TIMESTAMP NOT NULL,
|
||||||
|
@ -33,16 +41,15 @@ CREATE TABLE account_entry (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE transaction_attachment (
|
CREATE TABLE transaction_attachment (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
uploaded_at TIMESTAMP NOT NULL,
|
|
||||||
transaction_id BIGINT NOT NULL,
|
transaction_id BIGINT NOT NULL,
|
||||||
filename VARCHAR(255) NOT NULL,
|
attachment_id BIGINT NOT NULL,
|
||||||
content_type VARCHAR(255) NOT NULL,
|
PRIMARY KEY (transaction_id, attachment_id),
|
||||||
CONSTRAINT fk_transaction_attachment_transaction
|
CONSTRAINT fk_transaction_attachment_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT uq_transaction_attachment_filename
|
CONSTRAINT fk_transaction_attachment_attachment
|
||||||
UNIQUE(transaction_id, filename)
|
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE balance_record (
|
CREATE TABLE balance_record (
|
||||||
|
@ -55,3 +62,55 @@ CREATE TABLE balance_record (
|
||||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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,
|
profile, including but not limited to transaction attachments (receipts,
|
||||||
invoices, etc.), bank statements, or portfolio exports. These files are usually
|
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
|
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.
|
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
|
||||||
Here's an overview of where you can find everything:
|
regardless.
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue