diff --git a/finnow-api/bruno-api/Finnow/Accounts/Get Account Balance Time Series.bru b/finnow-api/bruno-api/Finnow/Accounts/Get Account Balance Time Series.bru new file mode 100644 index 0000000..9044d05 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Accounts/Get Account Balance Time Series.bru @@ -0,0 +1,20 @@ +meta { + name: Get Account Balance Time Series + type: http + seq: 5 +} + +get { + url: {{base_url}}/profiles/:profile/accounts/:accountId/balance-time-series + body: none + auth: inherit +} + +params:path { + profile: {{current_profile}} + accountId: 1 +} + +settings { + encodeUrl: true +} diff --git a/finnow-api/bruno-api/Finnow/collection.bru b/finnow-api/bruno-api/Finnow/collection.bru index ee278de..546d827 100644 --- a/finnow-api/bruno-api/Finnow/collection.bru +++ b/finnow-api/bruno-api/Finnow/collection.bru @@ -6,6 +6,10 @@ auth:bearer { token: {{access_token}} } +vars:pre-request { + current_profile: test-profile-0 +} + script:pre-request { const axios = require("axios"); const baseUrl = bru.getEnvVar("base_url"); @@ -16,7 +20,6 @@ script:pre-request { await checkAuth(); } - net = require("net") console.log("Testing GET /me"); try { const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}}); diff --git a/finnow-api/sample-data/attachments/1.txt b/finnow-api/sample-data/attachments/1.txt new file mode 100644 index 0000000..449c0a7 --- /dev/null +++ b/finnow-api/sample-data/attachments/1.txt @@ -0,0 +1 @@ +This is a sample text attachment. \ No newline at end of file diff --git a/finnow-api/sample-data/attachments/2.jpg b/finnow-api/sample-data/attachments/2.jpg new file mode 100644 index 0000000..15a1fdf Binary files /dev/null and b/finnow-api/sample-data/attachments/2.jpg differ diff --git a/finnow-api/sample-data/attachments/3.png b/finnow-api/sample-data/attachments/3.png new file mode 100644 index 0000000..9cfd251 Binary files /dev/null and b/finnow-api/sample-data/attachments/3.png differ diff --git a/finnow-api/sample-data/categories.csv b/finnow-api/sample-data/categories.csv new file mode 100644 index 0000000..1743ca7 --- /dev/null +++ b/finnow-api/sample-data/categories.csv @@ -0,0 +1,9 @@ +Food,#8ceb34,Anything food-related., +Groceries,#26c92c,Groceries for home-cooked meals.,Food +Restaurants,#bfd166,Restaurants and fast food meals., +Travel,#453ddb,"Cars, transit, and airfare.", +Health,#db3d6f,Personal healthcare., +Home,#f59e11,Home maintenance and upkeep., +Income,#20ab16,Any source of income., +Salary,#16ab40,Regular income from a salary.,Income +Interest,#8b16ab,Income from account interest.,Income diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 781a1f3..15e485b 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -138,6 +138,14 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons writeJsonBody(response, balances); } +void handleGetAccountBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) { + auto ds = getProfileDataSource(request); + ulong accountId = request.getPathParamOrThrow!ulong("accountId"); + int timeZoneOffset = request.getParamAs!int("time-zone-offset", 0); + auto series = getBalanceTimeSeries(ds, accountId, timeZoneOffset); + writeJsonBody(response, series); +} + // Value records: const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); diff --git a/finnow-api/source/account/service.d b/finnow-api/source/account/service.d index 605fc3e..531cb56 100644 --- a/finnow-api/source/account/service.d +++ b/finnow-api/source/account/service.d @@ -77,7 +77,33 @@ CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime ti } } return balances; +} +struct BalanceTimeSeriesPoint { + long balance; + string timestamp; +} + +BalanceTimeSeriesPoint[] getBalanceTimeSeries(ProfileDataSource ds, ulong accountId, int timeZoneOffsetMinutes) { + BalanceTimeSeriesPoint[] points; + immutable TimeZone tz = new immutable SimpleTimeZone(minutes(timeZoneOffsetMinutes)); + SysTime now = Clock.currTime(tz); + SysTime endOfToday = SysTime( + DateTime(now.year, now.month, now.day, 23, 59, 59), + tz + ); + SysTime timestamp = endOfToday.toOtherTZ(UTC()); + for (int i = 0; i < 30; i++) { + auto balance = getBalance(ds, accountId, timestamp); + if (!balance.isNull) { + points ~= BalanceTimeSeriesPoint( + balance.value, + timestamp.toISOExtString() + ); + } + timestamp = timestamp - days(1); + } + return points; } /** diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 9f6301b..0ceee9a 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -66,6 +66,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord); a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord); a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord); + a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/balance-time-series", &handleGetAccountBalanceTimeSeries); import transaction.api; // Transaction vendor endpoints: diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 60d8848..494e200 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -39,12 +39,10 @@ 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); } @@ -137,6 +135,7 @@ class SqlitePropertiesRepository : PropertiesRepository { private const SCHEMA = import("sql/schema.sql"); private const uint SCHEMA_VERSION = 1; +private const SCHEMA_VERSION_PROPERTY = "database-schema-version"; /** * An SQLite implementation of the ProfileDataSource that uses a single @@ -160,8 +159,12 @@ class SqliteProfileDataSource : ProfileDataSource { this.db = Database(path); db.run("PRAGMA foreign_keys = ON"); if (needsInit) { - infoF!"Initializing database: %s"(dbPath); + infoF!"Initializing database: %s with schema version %d."(dbPath, SCHEMA_VERSION); db.run(SCHEMA); + // Set the schema version property right away: + import std.conv; + auto propRepo = new SqlitePropertiesRepository(db); + propRepo.setProperty(SCHEMA_VERSION_PROPERTY, SCHEMA_VERSION.to!string); } migrateSchema(); } diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 53b63ed..f98e045 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -20,6 +20,7 @@ interface TransactionVendorRepository { interface TransactionCategoryRepository { Optional!TransactionCategory findById(ulong id); + Optional!TransactionCategory findByName(string name); bool existsById(ulong id); bool existsByName(string name); TransactionCategory[] findAll(); diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 711ef67..30b38f6 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -81,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository { return util.sqlite.findById(db, "transaction_category", &parseCategory, id); } + Optional!TransactionCategory findByName(string name) { + return util.sqlite.findOne(db, "SELECT * FROM transaction_category WHERE name = ?", &parseCategory, name); + } + bool existsById(ulong id) { return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id); } diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index f69e111..11c9513 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -12,11 +12,11 @@ struct TransactionVendor { } struct TransactionCategory { - immutable ulong id; - immutable Optional!ulong parentId; - immutable string name; - immutable string description; - immutable string color; + ulong id; + Optional!ulong parentId; + string name; + string description; + string color; } struct Transaction { diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 8bb2f4f..a44e0b1 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -1,7 +1,7 @@ module util.sample_data; import slf4d; -import handy_http_primitives : Optional; +import handy_http_primitives : Optional, mapIfPresent; import auth; import profile; @@ -46,7 +46,7 @@ void generateRandomUser(int idx, UserRepository userRepo) { infoF!"Generating random user %s, password: %s."(username, password); User user = createNewUser(userRepo, username, password); ProfileRepository profileRepo = new FileSystemProfileRepository(username); - const int profileCount = uniform(1, 5); + const int profileCount = uniform(1, 3); for (int i = 0; i < profileCount; i++) { generateRandomProfile(i, profileRepo); } @@ -59,16 +59,16 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) { ProfileDataSource ds = profileRepo.getDataSource(profile); ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); - Currency preferredCurrency = choice(ALL_CURRENCIES); + Currency preferredCurrency = Currencies.USD; - const int accountCount = uniform(3, 10); + const int accountCount = uniform(3, 8); for (int i = 0; i < accountCount; i++) { generateRandomAccount(i, ds, preferredCurrency); } ds.doTransaction(() { generateVendors(ds); - generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty); + generateCategories(ds); }); generateRandomTransactions(ds); } @@ -81,31 +81,27 @@ void generateVendors(ProfileDataSource ds) { vendorRepo.insert(record[0], record[1]); vendorCount++; } - infoF!" Generated %d random vendors."(vendorCount); + infoF!" Generated %d vendors."(vendorCount); } -void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) { - const int categoryCount = uniform(5, 10); - for (int i = 0; i < categoryCount; i++) { - string name = "Test Category " ~ to!string(i); - if (parentId) { - name ~= " (child of " ~ parentId.value.to!string ~ ")"; - } - TransactionCategory category = repo.insert( - parentId, - name, - "Testing category.", - "FFFFFF" - ); - infoF!" Generating child categories for %d, depth = %d"(i, depth); - if (depth < 2) { - generateCategories(repo, Optional!ulong.of(category.id), depth + 1); +void generateCategories(ProfileDataSource ds) { + auto categoryRepo = ds.getTransactionCategoryRepository(); + string categoriesCsv = readText("sample-data/categories.csv"); + uint categoryCount = 0; + foreach (record; csvReader!(Tuple!(string, string, string, string))(categoriesCsv)) { + string parentName = record[3]; + Optional!ulong parentId = Optional!ulong.empty; + if (parentName !is null && parentName.length > 0) { + parentId = categoryRepo.findByName(parentName).mapIfPresent!(c => c.id); } + categoryRepo.insert(parentId, record[0], record[2], record[1][1..$]); } + infoF!" Generated %d categories."(categoryCount); } void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) { AccountRepository accountRepo = ds.getAccountRepository(); + AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository(); string idxStr = idx.to!string; string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr; string name = "Test Account " ~ idxStr; @@ -122,6 +118,16 @@ void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurr currency, description ); + long balance = uniform(-1_000_000, 1_000_000); + ulong accountAge = uniform(5, 365*10); + SysTime accountOpenedAt = Clock.currTime(UTC()) - days(accountAge); + valueRecordRepo.insert( + accountOpenedAt, + account.id, + AccountValueRecordType.BALANCE, + balance, + account.currency + ); infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code); } @@ -202,7 +208,33 @@ void generateRandomTransactions(ProfileDataSource ds) { } } - auto txn = addTransaction(ds, data, []); + // Add attachments: + MultipartFile[] attachments; + if (uniform01 < 0.5) { + for (int k = 0; k < uniform(1, 3); k++) { + if (k == 0) { + attachments ~= MultipartFile( + "1.txt", + "text/plain", + cast(ubyte[]) std.file.read("sample-data/attachments/1.txt") + ); + } else if (k == 1) { + attachments ~= MultipartFile( + "2.jpg", + "image/jpg", + cast(ubyte[]) std.file.read("sample-data/attachments/2.jpg") + ); + } else if (k == 2) { + attachments ~= MultipartFile( + "3.png", + "image/png", + cast(ubyte[]) std.file.read("sample-data/attachments/3.png") + ); + } + } + } + + auto txn = addTransaction(ds, data, attachments); infoF!" Generated transaction %d"(txn.id); timestamp -= seconds(uniform(10, 1_000_000)); } diff --git a/web-app/package-lock.json b/web-app/package-lock.json index d97ead4..59910ac 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -14,6 +14,9 @@ "@fortawesome/vue-fontawesome": "^3.1.2", "@idle-observer/vue3": "^0.2.0", "chart.js": "^4.5.1", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "pinia": "^3.0.3", "vue": "^3.5.18", "vue-chartjs": "^5.3.2", @@ -2618,6 +2621,16 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2701,6 +2714,25 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/web-app/package.json b/web-app/package.json index 121c955..db3d5ce 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -22,6 +22,9 @@ "@fortawesome/vue-fontawesome": "^3.1.2", "@idle-observer/vue3": "^0.2.0", "chart.js": "^4.5.1", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "pinia": "^3.0.3", "vue": "^3.5.18", "vue-chartjs": "^5.3.2", diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index ea54543..9daf77f 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -143,6 +143,11 @@ export interface CurrencyBalance { balance: number } +export interface BalanceTimeSeriesPoint { + balance: number + timestamp: string +} + export class AccountApiClient extends ApiClient { readonly path: string readonly profileName: string @@ -189,6 +194,15 @@ export class AccountApiClient extends ApiClient { return super.getJson(`/profiles/${this.profileName}/account-balances`) } + getBalanceTimeSeries( + accountId: number, + timeZoneOffsetMinutes: number, + ): Promise { + return super.getJson( + `/profiles/${this.profileName}/accounts/${accountId}/balance-time-series?time-zone-offset=${timeZoneOffsetMinutes}`, + ) + } + getValueRecords(accountId: number, pageRequest: PageRequest): Promise> { return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest) } diff --git a/web-app/src/api/data.ts b/web-app/src/api/data.ts index 2fb6501..c0152b9 100644 --- a/web-app/src/api/data.ts +++ b/web-app/src/api/data.ts @@ -31,3 +31,7 @@ export function formatMoney(amount: number, currency: Currency) { export function floatMoneyToInteger(amount: number, currency: Currency) { return Math.round(amount * Math.pow(10, currency.fractionalDigits ?? 0)) } + +export function integerMoneyToFloat(amount: number, currency: Currency) { + return amount / Math.pow(10, currency.fractionalDigits ?? 0) +} diff --git a/web-app/src/components/common/FileSelector.vue b/web-app/src/components/common/FileSelector.vue index 09a1113..83584fa 100644 --- a/web-app/src/components/common/FileSelector.vue +++ b/web-app/src/components/common/FileSelector.vue @@ -1,5 +1,5 @@