diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index c2f5869..60d8848 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -39,10 +39,12 @@ class FileSystemProfileRepository : ProfileRepository { if (!exists(getProfilesDir())) mkdir(getProfilesDir()); ProfileDataSource ds = new SqliteProfileDataSource(path); import std.datetime; + import std.conv; auto propsRepo = ds.getPropertiesRepository(); propsRepo.setProperty("name", name); propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString()); propsRepo.setProperty("user", username); + propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string()); return new Profile(name); } @@ -133,6 +135,9 @@ class SqlitePropertiesRepository : PropertiesRepository { } } +private const SCHEMA = import("sql/schema.sql"); +private const uint SCHEMA_VERSION = 1; + /** * An SQLite implementation of the ProfileDataSource that uses a single * database connection to initialize various entity data access objects lazily. @@ -145,7 +150,6 @@ class SqliteProfileDataSource : ProfileDataSource { import attachment.data; import attachment.data_impl_sqlite; - const SCHEMA = import("sql/schema.sql"); private const string dbPath; Database db; @@ -159,6 +163,7 @@ class SqliteProfileDataSource : ProfileDataSource { infoF!"Initializing database: %s"(dbPath); db.run(SCHEMA); } + migrateSchema(); } PropertiesRepository getPropertiesRepository() return scope { @@ -200,4 +205,32 @@ class SqliteProfileDataSource : ProfileDataSource { void doTransaction(void delegate () dg) { util.sqlite.doTransaction(db, dg); } + + private void migrateSchema() { + import std.conv; + PropertiesRepository propsRepo = new SqlitePropertiesRepository(this.db); + uint currentVersion; + try { + currentVersion = propsRepo.findProperty("database-schema-version") + .mapIfPresent!(s => s.to!uint).orElse(0); + } catch (ConvException e) { + warn("Failed to parse database-schema-version property.", e); + currentVersion = 0; + } + if (currentVersion == SCHEMA_VERSION) return; + + static const migrations = [ + import("sql/migrations/1.sql") + ]; + static if (migrations.length != SCHEMA_VERSION) { + static assert(false, "Schema version doesn't match the list of defined migrations."); + } + + while (currentVersion < SCHEMA_VERSION) { + infoF!"Migrating schema from version %d to %d."(currentVersion, currentVersion + 1); + db.run(migrations[currentVersion]); + currentVersion++; + propsRepo.setProperty("database-schema-version", currentVersion.to!string); + } + } } diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 9f2c538..711ef67 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -264,48 +264,49 @@ class SqliteTransactionRepository : TransactionRepository { item.amount = row.peek!ulong(3); item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); item.description = row.peek!string(5); + item.internalTransfer = row.peek!bool(6); - Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); + Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); if (!vendorId.isNull) { item.vendor = Optional!(TransactionDetail.Vendor).of( TransactionDetail.Vendor( vendorId.get, - row.peek!string(7), - row.peek!string(8) + row.peek!string(8), + row.peek!string(9) )).toNullable; } - Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10); if (!categoryId.isNull) { item.category = Optional!(TransactionDetail.Category).of( TransactionDetail.Category( categoryId.get, - row.peek!(Nullable!ulong)(10), - row.peek!string(11), + row.peek!(Nullable!ulong)(11), row.peek!string(12), - row.peek!string(13) + row.peek!string(13), + row.peek!string(14) )).toNullable; } - Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14); + Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15); if (!creditedAccountId.isNull) { item.creditedAccount = Optional!(TransactionDetail.Account).of( TransactionDetail.Account( creditedAccountId.get, - row.peek!string(15), row.peek!string(16), - row.peek!string(17) + row.peek!string(17), + row.peek!string(18) )).toNullable; } - Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18); + Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19); if (!debitedAccountId.isNull) { item.debitedAccount = Optional!(TransactionDetail.Account).of( TransactionDetail.Account( debitedAccountId.get, - row.peek!string(19), row.peek!string(20), - row.peek!string(21) + row.peek!string(21), + row.peek!string(22) )).toNullable; } - string tagsStr = row.peek!string(22); + string tagsStr = row.peek!string(23); if (tagsStr !is null && tagsStr.length > 0) { import std.string : split; item.tags = tagsStr.split(","); @@ -353,6 +354,7 @@ class SqliteTransactionRepository : TransactionRepository { data.amount, data.currencyCode, data.description, + data.internalTransfer, data.vendorId, data.categoryId ); @@ -378,6 +380,7 @@ class SqliteTransactionRepository : TransactionRepository { data.amount, data.currencyCode, data.description, + data.internalTransfer, data.vendorId, data.categoryId, transactionId @@ -403,20 +406,6 @@ class SqliteTransactionRepository : TransactionRepository { ); } - static Transaction parseTransaction(Row row) { - import std.typecons : Nullable; - return Transaction( - row.peek!ulong(0), - SysTime.fromISOExtString(row.peek!string(1)), - SysTime.fromISOExtString(row.peek!string(2)), - row.peek!ulong(3), - Currency.ofCode(row.peek!(string, PeekMode.slice)(4)), - row.peek!string(5), - toOptional(row.peek!(Nullable!ulong)(6)), - toOptional(row.peek!(Nullable!ulong)(7)) - ); - } - /** * Function to parse a list of transaction list items as obtained from the * `get_transactions.sql` query. Because there are possibly multiple rows @@ -459,45 +448,46 @@ class SqliteTransactionRepository : TransactionRepository { item.amount = row.peek!ulong(3); item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); item.description = row.peek!string(5); + item.internalTransfer = row.peek!bool(6); // Read the nullable Vendor information. - Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); + Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); if (!vendorId.isNull) { - string vendorName = row.peek!string(7); + string vendorName = row.peek!string(8); item.vendor = Optional!(TransactionsListItem.Vendor).of( TransactionsListItem.Vendor(vendorId.get, vendorName)); } // Read the nullable Category information. - Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); if (!categoryId.isNull) { - string categoryName = row.peek!string(9); - string categoryColor = row.peek!string(10); + string categoryName = row.peek!string(10); + string categoryColor = row.peek!string(11); item.category = Optional!(TransactionsListItem.Category).of( TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); } // Read the nullable creditedAccount. - Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); + Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12); if (!creditedAccountId.isNull) { ulong id = creditedAccountId.get; - string name = row.peek!string(12); - string type = row.peek!string(13); - string suffix = row.peek!string(14); + string name = row.peek!string(13); + string type = row.peek!string(14); + string suffix = row.peek!string(15); item.creditedAccount = Optional!(TransactionsListItem.Account).of( TransactionsListItem.Account(id, name, type, suffix)); } // Read the nullable debitedAccount. - Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); + Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16); if (!debitedAccountId.isNull) { ulong id = debitedAccountId.get; - string name = row.peek!string(16); - string type = row.peek!string(17); - string suffix = row.peek!string(18); + string name = row.peek!string(17); + string type = row.peek!string(18); + string suffix = row.peek!string(19); item.debitedAccount = Optional!(TransactionsListItem.Account).of( TransactionsListItem.Account(id, name, type, suffix)); } } // Read multi-row properties, like tags, to the current item. - string tag = row.peek!string(19); + string tag = row.peek!string(20); if (tag !is null) { item.tags ~= tag; } @@ -509,56 +499,6 @@ class SqliteTransactionRepository : TransactionRepository { return app[]; } - static TransactionsListItem parseTransactionsListItem(Row row) { - TransactionsListItem item; - item.id = row.peek!ulong(0); - item.timestamp = row.peek!string(1); - item.addedAt = row.peek!string(2); - item.amount = row.peek!ulong(3); - item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); - item.description = row.peek!string(5); - - Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); - if (!vendorId.isNull) { - string vendorName = row.peek!string(7); - item.vendor = Optional!(TransactionsListItem.Vendor).of( - TransactionsListItem.Vendor(vendorId.get, vendorName)); - } - Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); - if (!categoryId.isNull) { - string categoryName = row.peek!string(9); - string categoryColor = row.peek!string(10); - item.category = Optional!(TransactionsListItem.Category).of( - TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); - } - Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); - if (!creditedAccountId.isNull) { - ulong id = creditedAccountId.get; - string name = row.peek!string(12); - string type = row.peek!string(13); - string suffix = row.peek!string(14); - item.creditedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); - if (!debitedAccountId.isNull) { - ulong id = debitedAccountId.get; - string name = row.peek!string(16); - string type = row.peek!string(17); - string suffix = row.peek!string(18); - item.debitedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - string tagsStr = row.peek!string(19); - if (tagsStr !is null && tagsStr.length > 0) { - import std.string : split; - item.tags = tagsStr.split(","); - } else { - item.tags = []; - } - return item; - } - private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { foreach (size_t idx, lineItem; data.lineItems) { util.sqlite.update( @@ -595,6 +535,7 @@ class SqliteTransactionRepository : TransactionRepository { .select("txn.amount") .select("txn.currency") .select("txn.description") + .select("txn.internal_transfer") .select("txn.vendor_id") .select("vendor.name") .select("txn.category_id") diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index 35c33d3..0e6e541 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -17,6 +17,7 @@ struct TransactionsListItem { ulong amount; Currency currency; string description; + bool internalTransfer; @serdeTransformOut!serializeOptional Optional!Vendor vendor; @serdeTransformOut!serializeOptional @@ -54,6 +55,7 @@ struct TransactionDetail { ulong amount; Currency currency; string description; + bool internalTransfer; Nullable!Vendor vendor; Nullable!Category category; Nullable!Account creditedAccount; @@ -98,6 +100,7 @@ struct AddTransactionPayload { ulong amount; string currencyCode; string description; + bool internalTransfer; Nullable!ulong vendorId; Nullable!ulong categoryId; Nullable!ulong creditedAccountId; diff --git a/finnow-api/source/transaction/search_filters.d b/finnow-api/source/transaction/search_filters.d index dec6057..6883d25 100644 --- a/finnow-api/source/transaction/search_filters.d +++ b/finnow-api/source/transaction/search_filters.d @@ -66,6 +66,25 @@ void applyFilters(ref QueryBuilder qb, in ServerHttpRequest request) { }); } } + + // Boolean filter for internal transfer. + if (request.hasParam("internal-transfer")) { + string value = request.getParamValues("internal-transfer")[0] + .strip() + .toUpper(); + Optional!bool internalTransferFilter = Optional!bool.empty(); + if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") { + internalTransferFilter = Optional!bool.of(true); + } else if (value == "N" || value == "NO" || value == "FALSE" || value == "0") { + internalTransferFilter = Optional!bool.of(false); + } + if (!internalTransferFilter.isNull) { + qb.where("txn.internal_transfer = ?"); + qb.withArgBinding((ref stmt, ref idx) { + stmt.bind(idx++, internalTransferFilter.value); + }); + } + } // Textual search query: if (request.hasParam("q")) { diff --git a/finnow-api/sql/get_transaction.sql b/finnow-api/sql/get_transaction.sql index 8761086..c1beb3c 100644 --- a/finnow-api/sql/get_transaction.sql +++ b/finnow-api/sql/get_transaction.sql @@ -5,6 +5,7 @@ txn.added_at AS added_at, txn.amount AS amount, txn.currency AS currency, txn.description AS description, +txn.internal_transfer AS internal_transfer, txn.vendor_id AS vendor_id, vendor.name AS vendor_name, diff --git a/finnow-api/sql/insert_transaction.sql b/finnow-api/sql/insert_transaction.sql index 9785711..ec65a81 100644 --- a/finnow-api/sql/insert_transaction.sql +++ b/finnow-api/sql/insert_transaction.sql @@ -4,6 +4,7 @@ INSERT INTO "transaction" ( amount, currency, description, + internal_transfer, vendor_id, category_id -) VALUES (?, ?, ?, ?, ?, ?, ?) \ No newline at end of file +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/finnow-api/sql/migrations/1.sql b/finnow-api/sql/migrations/1.sql new file mode 100644 index 0000000..e29fce1 --- /dev/null +++ b/finnow-api/sql/migrations/1.sql @@ -0,0 +1,2 @@ +ALTER TABLE "transaction" +ADD COLUMN internal_transfer BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/finnow-api/sql/schema.sql b/finnow-api/sql/schema.sql index a54d1f0..dfb24c7 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -63,6 +63,7 @@ CREATE TABLE "transaction" ( amount INTEGER NOT NULL, currency TEXT NOT NULL, description TEXT, + internal_transfer BOOLEAN NOT NULL DEFAULT FALSE, vendor_id INTEGER, category_id INTEGER, CONSTRAINT fk_transaction_vendor diff --git a/finnow-api/sql/update_transaction.sql b/finnow-api/sql/update_transaction.sql index cabc56e..4c04bc7 100644 --- a/finnow-api/sql/update_transaction.sql +++ b/finnow-api/sql/update_transaction.sql @@ -4,6 +4,7 @@ SET amount = ?, currency = ?, description = ?, + internal_transfer = ?, vendor_id = ?, category_id = ? WHERE id = ? \ No newline at end of file diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 83ed3a2..53cd70e 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -32,17 +32,6 @@ export interface TransactionCategoryTree { depth: number } -export interface Transaction { - id: number - timestamp: string - addedAt: string - amount: number - currency: string - description: string - vendorId: number | null - categoryId: number | null -} - export interface TransactionsListItem { id: number timestamp: string @@ -50,6 +39,7 @@ export interface TransactionsListItem { amount: number currency: Currency description: string + internalTransfer: boolean vendor: TransactionsListItemVendor | null category: TransactionsListItemCategory | null creditedAccount: TransactionsListItemAccount | null @@ -82,6 +72,7 @@ export interface TransactionDetail { amount: number currency: Currency description: string + internalTransfer: boolean vendor: TransactionVendor | null category: TransactionCategory | null creditedAccount: TransactionDetailAccount | null @@ -111,6 +102,7 @@ export interface AddTransactionPayload { amount: number currencyCode: string description: string + internalTransfer: boolean vendorId: number | null categoryId: number | null creditedAccountId: number | null diff --git a/web-app/src/components/IdleTimeoutModal.vue b/web-app/src/components/IdleTimeoutModal.vue index e214421..1874a8f 100644 --- a/web-app/src/components/IdleTimeoutModal.vue +++ b/web-app/src/components/IdleTimeoutModal.vue @@ -4,14 +4,14 @@ import { useTemplateRef, ref, computed } from 'vue' import ModalWrapper from './common/ModalWrapper.vue' import AppButton from './common/AppButton.vue' -const timeoutModal = useTemplateRef("timeoutModal") +const timeoutModal = useTemplateRef('timeoutModal') const secondsUntilLogout = ref(30) const timeoutTimerId = ref() const timePhrase = computed(() => { if (secondsUntilLogout.value !== 1) { - return secondsUntilLogout.value + " seconds" + return secondsUntilLogout.value + ' seconds' } - return secondsUntilLogout.value + " second" + return secondsUntilLogout.value + ' second' }) const authStore = useAuthStore() @@ -40,8 +40,8 @@ defineExpose({ start })