finnow/finnow-api/source/analytics/balances.d

154 lines
4.9 KiB
D

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;
}