diff --git a/finnow-api/schema.sql b/finnow-api/schema.sql index b6888b0..5e65577 100644 --- a/finnow-api/schema.sql +++ b/finnow-api/schema.sql @@ -141,9 +141,9 @@ CREATE TABLE account_value_record ( timestamp TEXT NOT NULL, account_id INTEGER NOT NULL, type TEXT NOT NULL DEFAULT 'BALANCE', - balance INTEGER NOT NULL, + value INTEGER NOT NULL, currency TEXT NOT NULL, - CONSTRAINT fk_balance_record_account + CONSTRAINT fk_account_value_record_account FOREIGN KEY (account_id) REFERENCES account(id) ON UPDATE CASCADE ON DELETE CASCADE ); diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 94e03af..061f7fb 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -6,6 +6,8 @@ import account.model; import util.money; import history.model; +import std.datetime : SysTime; + interface AccountRepository { Optional!Account findById(ulong id); Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description); @@ -17,3 +19,16 @@ interface AccountRepository { void setCreditCardProperties(ulong id, in AccountCreditCardProperties props); History getHistory(ulong id); } + +interface AccountJournalEntryRepository { + Optional!AccountJournalEntry findById(ulong id); + AccountJournalEntry insert( + SysTime timestamp, + ulong accountId, + ulong transactionId, + ulong amount, + AccountJournalEntryType type, + Currency currency + ); + void deleteById(ulong id); +} diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index 175963b..b919f08 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -185,3 +185,63 @@ SQL", ); } } + +class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!AccountJournalEntry findById(ulong id) { + return util.sqlite.findById(db, "account_journal_entry", &parseEntry, id); + } + + AccountJournalEntry insert( + SysTime timestamp, + ulong accountId, + ulong transactionId, + ulong amount, + AccountJournalEntryType type, + Currency currency + ) { + util.sqlite.update( + db, + "INSERT INTO account_journal_entry + (timestamp, account_id, transaction_id, amount, type, currency) + VALUES (?, ?, ?, ?, ?, ?)", + timestamp.toISOExtString(), + accountId, + transactionId, + amount, + type, + currency.code + ); + ulong id = db.lastInsertRowid(); + return findById(id).orElseThrow(); + } + + void deleteById(ulong id) { + util.sqlite.deleteById(db, "account_journal_entry", id); + } + + static AccountJournalEntry parseEntry(Row row) { + string typeStr = row.peek!(string, PeekMode.slice)(5); + AccountJournalEntryType type; + if (typeStr == "CREDIT") { + type = AccountJournalEntryType.CREDIT; + } else if (typeStr == "DEBIT") { + type = AccountJournalEntryType.DEBIT; + } else { + throw new Exception("Invalid account journal entry type: " ~ typeStr); + } + return AccountJournalEntry( + row.peek!ulong(0), + SysTime.fromISOExtString(row.peek!string(1)), + row.peek!ulong(2), + row.peek!ulong(3), + row.peek!ulong(4), + type, + Currency.ofCode(row.peek!(string, PeekMode.slice)(6)) + ); + } +} diff --git a/finnow-api/source/account/model.d b/finnow-api/source/account/model.d index 83add8f..d636196 100644 --- a/finnow-api/source/account/model.d +++ b/finnow-api/source/account/model.d @@ -6,9 +6,9 @@ import std.traits : EnumMembers; import util.money; struct AccountType { - const string id; - const string name; - const bool debitsPositive; + immutable string id; + immutable string name; + immutable bool debitsPositive; static AccountType fromId(string id) { static foreach (t; ALL_ACCOUNT_TYPES) { @@ -28,17 +28,45 @@ enum AccountTypes : AccountType { immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ]; struct Account { - const ulong id; - const SysTime createdAt; - const bool archived; - const AccountType type; - const string numberSuffix; - const string name; - const Currency currency; - const string description; + immutable ulong id; + immutable SysTime createdAt; + immutable bool archived; + immutable AccountType type; + immutable string numberSuffix; + immutable string name; + immutable Currency currency; + immutable string description; } struct AccountCreditCardProperties { - const ulong account_id; - const long creditLimit; + immutable ulong account_id; + immutable long creditLimit; +} + +enum AccountJournalEntryType : string { + CREDIT = "CREDIT", + DEBIT = "DEBIT" +} + +struct AccountJournalEntry { + immutable ulong id; + immutable SysTime timestamp; + immutable ulong accountId; + immutable ulong transactionId; + immutable ulong amount; + immutable AccountJournalEntryType type; + immutable Currency currency; +} + +enum AccountValueRecordType : string { + BALANCE = "BALANCE" +} + +struct AccountValueRecord { + immutable ulong id; + immutable SysTime timestamp; + immutable ulong accountId; + immutable AccountValueRecordType type; + immutable long value; + immutable Currency currency; } diff --git a/finnow-api/source/attachment/data_impl_sqlite.d b/finnow-api/source/attachment/data_impl_sqlite.d index 8bb7831..49e0ed1 100644 --- a/finnow-api/source/attachment/data_impl_sqlite.d +++ b/finnow-api/source/attachment/data_impl_sqlite.d @@ -58,11 +58,11 @@ SQL", static Attachment parseAttachment(Row row) { return Attachment( row.peek!ulong(0), - SysTime.fromISOExtString(row.peek!string(1), UTC()), + parseISOTimestamp(row, 1), row.peek!string(2), row.peek!string(3), row.peek!ulong(4), - row.peek!(ubyte[])(5) + parseBlob(row, 5) ); } } diff --git a/finnow-api/source/attachment/model.d b/finnow-api/source/attachment/model.d index 97c5461..62f1dbc 100644 --- a/finnow-api/source/attachment/model.d +++ b/finnow-api/source/attachment/model.d @@ -3,10 +3,10 @@ module attachment.model; import std.datetime; struct Attachment { - ulong id; - SysTime uploadedAt; - string filename; - string contentType; - ulong size; - ubyte[] content; + immutable ulong id; + immutable SysTime uploadedAt; + immutable string filename; + immutable string contentType; + immutable ulong size; + immutable ubyte[] content; } diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 7641ee2..81f5be9 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -25,8 +25,18 @@ interface PropertiesRepository { * gateway for all data access operations for a profile. */ interface ProfileDataSource { - import account.data : AccountRepository; + import account.data; + import transaction.data; PropertiesRepository getPropertiesRepository(); + AccountRepository getAccountRepository(); + AccountJournalEntryRepository getAccountJournalEntryRepository(); + + TransactionVendorRepository getTransactionVendorRepository(); + TransactionCategoryRepository getTransactionCategoryRepository(); + TransactionTagRepository getTransactionTagRepository(); + TransactionRepository getTransactionRepository(); + + void doTransaction(void delegate () dg); } diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index aa72714..02defe8 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -141,6 +141,8 @@ class SqlitePropertiesRepository : PropertiesRepository { class SqliteProfileDataSource : ProfileDataSource { import account.data; import account.data_impl_sqlite; + import transaction.data; + import transaction.data_impl_sqlite; const SCHEMA = import("schema.sql"); private const string dbPath; @@ -164,4 +166,28 @@ class SqliteProfileDataSource : ProfileDataSource { AccountRepository getAccountRepository() { return new SqliteAccountRepository(db); } + + AccountJournalEntryRepository getAccountJournalEntryRepository() { + return new SqliteAccountJournalEntryRepository(db); + } + + TransactionVendorRepository getTransactionVendorRepository() { + return new SqliteTransactionVendorRepository(db); + } + + TransactionCategoryRepository getTransactionCategoryRepository() { + return new SqliteTransactionCategoryRepository(db); + } + + TransactionTagRepository getTransactionTagRepository() { + return new SqliteTransactionTagRepository(db); + } + + TransactionRepository getTransactionRepository() { + return new SqliteTransactionRepository(db); + } + + void doTransaction(void delegate () dg) { + util.sqlite.doTransaction(db, dg); + } } diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index d882450..436f05a 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -179,3 +179,59 @@ class SqliteTransactionTagRepository : TransactionTagRepository { ); } } + +class SqliteTransactionRepository : TransactionRepository { + private const TABLE_NAME = "\"transaction\""; + private Database db; + this(Database db) { + this.db = db; + } + + Optional!Transaction findById(ulong id) { + return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id); + } + + Transaction insert( + SysTime timestamp, + SysTime addedAt, + ulong amount, + Currency currency, + string description, + Optional!ulong vendorId, + Optional!ulong categoryId + ) { + util.sqlite.update( + db, + "INSERT INTO " ~ TABLE_NAME ~ " + (timestamp, added_at, amount, currency, description, vendor_id, category_id) + VALUES (?, ?, ?, ?, ?, ?, ?)", + timestamp.toISOExtString(), + addedAt.toISOExtString(), + amount, + currency.code, + description, + vendorId.asNullable, + categoryId.asNullable + ); + ulong id = db.lastInsertRowid(); + return findById(id).orElseThrow(); + } + + void deleteById(ulong id) { + util.sqlite.deleteById(db, TABLE_NAME, id); + } + + 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), + Optional!(ulong).of(row.peek!(Nullable!ulong)(6)), + Optional!(ulong).of(row.peek!(Nullable!ulong)(7)) + ); + } +} diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index fd3c9a2..cb2f5bb 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -6,43 +6,43 @@ import std.datetime; import util.money; struct TransactionVendor { - const ulong id; - const string name; - const string description; + immutable ulong id; + immutable string name; + immutable string description; } struct TransactionCategory { - const ulong id; - const Optional!ulong parentId; - const string name; - const string description; - const string color; + immutable ulong id; + immutable Optional!ulong parentId; + immutable string name; + immutable string description; + immutable string color; } struct TransactionTag { - const ulong id; - const string name; + immutable ulong id; + immutable string name; } struct Transaction { - const ulong id; + immutable ulong id; /// The time at which the transaction happened. - const SysTime timestamp; + immutable SysTime timestamp; /// The time at which the transaction entity was saved. - const SysTime addedAt; - const ulong amount; - const Currency currency; - const string description; - const Optional!ulong vendorId; - const Optional!ulong categoryId; + immutable SysTime addedAt; + immutable ulong amount; + immutable Currency currency; + immutable string description; + immutable Optional!ulong vendorId; + immutable Optional!ulong categoryId; } struct TransactionLineItem { - const ulong id; - const ulong transactionId; - const long valuePerItem; - const ulong quantity; - const uint idx; - const string description; - const Optional!ulong categoryId; + immutable ulong id; + immutable ulong transactionId; + immutable long valuePerItem; + immutable ulong quantity; + immutable uint idx; + immutable string description; + immutable Optional!ulong categoryId; } diff --git a/finnow-api/source/transaction/package.d b/finnow-api/source/transaction/package.d index 56af348..7fa5ec8 100644 --- a/finnow-api/source/transaction/package.d +++ b/finnow-api/source/transaction/package.d @@ -2,3 +2,4 @@ module transaction; public import transaction.data; public import transaction.model; +public import transaction.service; diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d new file mode 100644 index 0000000..1edc496 --- /dev/null +++ b/finnow-api/source/transaction/service.d @@ -0,0 +1,62 @@ +module transaction.service; + +import handy_httpd.components.optional; +import std.datetime; + +import transaction.model; +import transaction.data; +import profile.data; +import util.money; +import account.model; + +void addTransaction( + ProfileDataSource ds, + SysTime timestamp, + SysTime addedAt, + ulong amount, + Currency currency, + string description, + Optional!ulong vendorId, + Optional!ulong categoryId, + Optional!ulong creditedAccountId, + Optional!ulong debitedAccountId, + TransactionLineItem[] lineItems + // TODO: Add attachments and tags! +) { + if (creditedAccountId.isNull && debitedAccountId.isNull) { + throw new Exception("At least one account must be linked to a transaction."); + } + ds.doTransaction(() { + auto journalEntryRepo = ds.getAccountJournalEntryRepository(); + auto txRepo = ds.getTransactionRepository(); + Transaction tx = txRepo.insert( + timestamp, + addedAt, + amount, + currency, + description, + vendorId, + categoryId + ); + if (creditedAccountId) { + journalEntryRepo.insert( + timestamp, + creditedAccountId.value, + tx.id, + amount, + AccountJournalEntryType.CREDIT, + currency + ); + } + if (debitedAccountId) { + journalEntryRepo.insert( + timestamp, + debitedAccountId.value, + tx.id, + amount, + AccountJournalEntryType.DEBIT, + currency + ); + } + }); +} diff --git a/finnow-api/source/util/money.d b/finnow-api/source/util/money.d index 580fac6..2e6ea7b 100644 --- a/finnow-api/source/util/money.d +++ b/finnow-api/source/util/money.d @@ -8,11 +8,11 @@ import std.traits : isSomeString, EnumMembers; */ struct Currency { /// The common 3-character code for the currency, like "USD". - const char[3] code; + immutable char[3] code; /// The number of digits after the decimal place that the currency supports. - const ubyte fractionalDigits; + immutable ubyte fractionalDigits; /// The ISO 4217 numeric code for the currency. - const ushort numericCode; + immutable ushort numericCode; static Currency ofCode(S)(S code) if (isSomeString!S) { if (code.length != 3) { @@ -50,8 +50,8 @@ unittest { * so for example, with USD currency, a value of 123 indicates $1.23. */ struct MoneyValue { - const Currency currency; - const long value; + immutable Currency currency; + immutable long value; int opCmp(in MoneyValue other) const { if (other.currency != this.currency) return 0; diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 4a07b34..7c95498 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -1,15 +1,18 @@ module util.sample_data; import slf4d; +import handy_httpd.components.optional; import auth; import profile; import account; +import transaction; import util.money; import std.random; import std.conv; import std.array; +import std.datetime; void generateSampleData() { UserRepository userRepo = new FileSystemUserRepository; @@ -20,7 +23,13 @@ void generateSampleData() { const int userCount = uniform(5, 10); for (int i = 0; i < userCount; i++) { - generateRandomUser(i, userRepo); + try { + generateRandomUser(i, userRepo); + } catch (Throwable t) { + import std.stdio; + stderr.writeln(t); + throw t; + } } info("Random sample data generation complete."); } @@ -48,6 +57,29 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) { for (int i = 0; i < accountCount; i++) { generateRandomAccount(i, ds); } + + auto vendorRepo = ds.getTransactionVendorRepository(); + const int vendorCount = uniform(5, 30); + for (int i = 0; i < vendorCount; i++) { + vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data."); + } + infoF!" Generated %d random vendors."(vendorCount); + + auto tagRepo = ds.getTransactionTagRepository(); + const int tagCount = uniform(5, 30); + for (int i = 0; i < tagCount; i++) { + tagRepo.insert("test-tag-" ~ to!string(i)); + } + infoF!" Generated %d random tags."(tagCount); + + auto categoryRepo = ds.getTransactionCategoryRepository(); + const int categoryCount = uniform(5, 30); + for (int i = 0; i < categoryCount; i++) { + categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF"); + } + infoF!" Generated %d random categories."(categoryCount); + + generateRandomTransactions(ds); } void generateRandomAccount(int idx, ProfileDataSource ds) { @@ -58,7 +90,6 @@ void generateRandomAccount(int idx, ProfileDataSource ds) { AccountType type = choice(ALL_ACCOUNT_TYPES); Currency currency = choice(ALL_CURRENCIES); string description = "This is a testing account generated by util.sample_data.generateRandomAccount()."; - infoF!" Generating random account: %s, ...%s"(name, numberSuffix); Account account = accountRepo.insert( type, numberSuffix, @@ -66,4 +97,65 @@ void generateRandomAccount(int idx, ProfileDataSource ds) { currency, description ); + infoF!" Generated random account: %s, #%s"(name, numberSuffix); +} + +void generateRandomTransactions(ProfileDataSource ds) { + const bool hasVendor = uniform01() > 0.3; + const bool hasCategory = uniform01() > 0.2; + const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll(); + const TransactionCategory[] categories = ds.getTransactionCategoryRepository() + .findAllByParentId(Optional!ulong.empty); + const TransactionTag[] tags = ds.getTransactionTagRepository().findAll(); + const Account[] accounts = ds.getAccountRepository().findAll(); + + SysTime now = Clock.currTime(UTC()); + SysTime timestamp = Clock.currTime(UTC()) - seconds(1); + + for (int i = 0; i < 1000; i++) { + Optional!ulong vendorId; + if (hasVendor) { + vendorId = Optional!ulong.of(choice(vendors).id); + } + Optional!ulong categoryId; + if (hasCategory) { + categoryId = Optional!ulong.of(choice(categories).id); + } + Optional!ulong creditedAccountId; + Optional!ulong debitedAccountId; + Account primaryAccount = choice(accounts); + Optional!ulong secondaryAccount; + if (uniform01() < 0.25) { + foreach (acc; accounts) { + if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) { + secondaryAccount.value = acc.id; + break; + } + } + } + if (uniform01() > 0.5) { + creditedAccountId = Optional!ulong.of(primaryAccount.id); + if (secondaryAccount) debitedAccountId = secondaryAccount; + } else { + debitedAccountId = Optional!ulong.of(primaryAccount.id); + if (secondaryAccount) creditedAccountId = secondaryAccount; + } + ulong value = uniform(0, 1_000_000); + + addTransaction( + ds, + timestamp, + now, + value, + primaryAccount.currency, + "Test transaction " ~ to!string(i), + vendorId, + categoryId, + creditedAccountId, + debitedAccountId, + [] + ); + infoF!" Generated transaction %d"(i); + timestamp -= seconds(uniform(10, 1_000_000)); + } } diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d index e644830..5898cc8 100644 --- a/finnow-api/source/util/sqlite.d +++ b/finnow-api/source/util/sqlite.d @@ -1,5 +1,7 @@ module util.sqlite; +import std.datetime; + import slf4d; import handy_httpd.components.optional; import d2sqlite3; @@ -31,7 +33,7 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result * Returns: An optional result. */ Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) { - Statement stmt = db.prepare("SELECT * FROM " ~ table); + Statement stmt = db.prepare("SELECT * FROM " ~ table ~ " WHERE id = ?"); stmt.bind(1, id); ResultRange result = stmt.execute(); if (result.empty) return Optional!T.empty; @@ -123,3 +125,44 @@ T doTransaction(T)(Database db, T delegate() dg) { throw e; } } + +/** + * Executes a "SELECT COUNT..." query on a database. + * Params: + * db = The database to use. + * query = The query to use, which must return an integer as its sole result. + * args = Arguments to provide to the query. + * Returns: The count returned by the query. + */ +ulong count(Args...)(Database db, string query, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + ResultRange result = stmt.execute(); + if (result.empty) return 0; + return result.front.peek!ulong(0); +} + +/** + * Reads an ISO-8601 UTC timestamp from a result row. + * Params: + * row = The row to read from. + * idx = The column index in the row to read. + * Returns: The timestamp that was read. + */ +SysTime parseISOTimestamp(Row row, size_t idx) { + return SysTime.fromISOExtString( + row.peek!(string, PeekMode.slice)(idx), + UTC() + ); +} + +/** + * Reads a set of bytes from a result row. + * Params: + * row = The row to read from. + * idx = The column index in the row to read. + * Returns: The blob data that was read. + */ +immutable(ubyte[]) parseBlob(Row row, size_t idx) { + return row.peek!(ubyte[], PeekMode.slice)(idx).idup; +}