154 lines
4.9 KiB
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;
|
|
}
|