module analytics.balances; import handy_http_primitives : Optional, mapIfPresent; import std.datetime; import std.stdio; import std.path; import std.file; import slf4d; import asdf; import profile.data; import profile.model; import account.data; import account.model; import account.service; import analytics.util; import transaction.model; import transaction.dto; import util.pagination; struct TimeSeriesPoint { /// The millisecond UTC timestamp. ulong x; /// The value at this timestamp. long y; } alias CurrencyGroupedTimeSeries = TimeSeriesPoint[][string]; struct AccountBalanceData { ulong accountId; string currencyCode; TimeSeriesPoint[] data; } struct BalanceTimeSeriesAnalytics { AccountBalanceData[] accounts; CurrencyGroupedTimeSeries totals; } void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileRepo) { ProfileDataSource ds = profileRepo.getDataSource(profile); Account[] accounts = ds.getAccountRepository().findAll(); // Initialize the data structure that'll store the analytics info. BalanceTimeSeriesAnalytics data; foreach (account; accounts) { AccountBalanceData accountData; accountData.accountId = account.id; accountData.currencyCode = account.currency.code.idup; data.accounts ~= accountData; } foreach (timestamp; generateTimeSeriesTimestamps(days(1), 365)) { // Compute the balance of each account at this timestamp. foreach (idx, account; accounts) { auto balance = getBalance(ds, account.id, timestamp); if (!balance.isNull) { data.accounts[idx].data ~= TimeSeriesPoint( timestamp.toUnixMillis(), balance.value ); } } // Compute total balances for this timestamp. auto totalBalances = getTotalBalanceForAllAccounts(ds, timestamp); foreach (CurrencyBalance bal; totalBalances) { data.totals[bal.currency.code.idup] ~= TimeSeriesPoint(timestamp.toUnixMillis(), bal.balance); } } ds.doTransaction(() { ds.getPropertiesRepository().deleteProperty("analytics.balanceTimeSeries"); ds.getPropertiesRepository().setProperty( "analytics.balanceTimeSeries", serializeToJsonPretty(data) ); }); infoF!"Computed account balance analytics for user %s, profile %s."( profile.username, profile.name ); } struct CategorySpendData { ulong categoryId; string categoryName; string categoryColor; CurrencyGroupedTimeSeries dataByCurrency; } struct CategorySpendTimeSeriesAnalytics { CategorySpendData[] categories; } void computeCategorySpendTimeSeries(Profile profile, ProfileRepository profileRepo) { ProfileDataSource ds = profileRepo.getDataSource(profile); TransactionCategory[] rootCategories = ds.getTransactionCategoryRepository() .findAllByParentId(Optional!ulong.empty); CategorySpendTimeSeriesAnalytics data; PageRequest pr = PageRequest(1, 100, [Sort("txn.timestamp", SortDir.ASC)]); Page!TransactionsListItem page = ds.getTransactionRepository().findAll(pr); while (page.items.length > 0) { foreach (TransactionsListItem txn; page.items) { if (!isTypicalSpendingTransaction(txn)) continue; ulong categoryId = txn.category.mapIfPresent!(c => c.id).orElse(0); TimeSeriesPoint dataPoint = TimeSeriesPoint( SysTime.fromISOExtString(txn.timestamp).toUnixMillis(), txn.amount ); bool foundCategory = false; foreach (ref cd; data.categories) { if (cd.categoryId == categoryId) { cd.dataByCurrency[txn.currency.code.idup] ~= dataPoint; foundCategory = true; break; } } if (!foundCategory) { CategorySpendData cd; cd.categoryId = categoryId; cd.categoryName = txn.category.mapIfPresent!(c => c.name).orElse("None"); cd.categoryColor = txn.category.mapIfPresent!(c => c.color).orElse("FFFFFF"); cd.dataByCurrency[txn.currency.code.idup] ~= dataPoint; data.categories ~= cd; } } page = ds.getTransactionRepository().findAll(page.pageRequest.next()); } ds.doTransaction(() { ds.getPropertiesRepository().deleteProperty("analytics.categorySpendTimeSeries"); ds.getPropertiesRepository().setProperty( "analytics.categorySpendTimeSeries", serializeToJsonPretty(data) ); }); infoF!"Computed category spend analytics for user %s, profile %s."( profile.username, profile.name ); } private bool isTypicalSpendingTransaction(in TransactionsListItem txn) { return !txn.creditedAccount.isNull && txn.debitedAccount.isNull && !txn.internalTransfer; }