module analytics.modules.balances; import handy_http_primitives : Optional, mapIfPresent; import std.datetime; import std.stdio; import std.path; import std.file; import std.algorithm; import std.array; import std.conv; 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; import util.money; import util.data; import analytics.data; struct AccountBalanceData { ulong accountId; TimeSeriesPoint[] data; } struct BalanceTimeSeriesAnalytics { AccountBalanceData[] accounts; TimeSeriesPoint[] totals; } /** * Computes a time series tracking the balance of each account (and total of * all accounts) over the given time range. * Params: * ds = The profile data source. * currency = The currency to report data for. * timeRange = The time range to generate the time series for. * Returns: An analytics response containing a "totals" time series, as well * as a time series for each known account in the given time range. */ BalanceTimeSeriesAnalytics computeBalanceTimeSeries( ProfileDataSource ds, in Currency currency, in TimeRange timeRange ) { SysTime[] timestamps = generateTimeSeriesTimestamps(days(1), timeRange); AnalyticsRepository repo = ds.getAnalyticsRepository(); JournalEntryStub[] journalEntries = repo.getJournalEntries(currency, timeRange); BalanceRecordStub[] balanceRecords = repo.getBalanceRecords(currency, timeRange); auto accountIds = balanceRecords.map!(br => br.accountId).uniq.array.sort; BalanceTimeSeriesAnalytics result; foreach (timestamp; timestamps) { long totalBalance = 0; foreach (accountId; accountIds) { Optional!long optionalBalance = deriveBalance(accountId, journalEntries, balanceRecords, timestamp); if (!optionalBalance.isNull) { TimeSeriesPoint p = TimeSeriesPoint(timestamp.toUnixMillis(), optionalBalance.value); bool isAccountDataPresent = false; foreach (ref accountData; result.accounts) { if (accountData.accountId == accountId) { accountData.data ~= p; isAccountDataPresent = true; break; } } if (!isAccountDataPresent) { result.accounts ~= AccountBalanceData(accountId, [p]); } totalBalance += optionalBalance.value; } } result.totals ~= TimeSeriesPoint(timestamp.toUnixMillis(), totalBalance); } return result; } private Optional!long deriveBalance( ulong accountId, in JournalEntryStub[] journalEntries, in BalanceRecordStub[] balanceRecords, in SysTime timestamp ) { import core.time : abs; Optional!BalanceRecordStub nearestBalanceRecord; foreach (br; balanceRecords) { if (br.accountId == accountId) { if (nearestBalanceRecord.isNull) { nearestBalanceRecord = Optional!(BalanceRecordStub).of(br); } else { Duration currentDiff = abs(nearestBalanceRecord.value.timestamp - timestamp); Duration newDiff = abs(br.timestamp - timestamp); if (newDiff < currentDiff) { nearestBalanceRecord = Optional!(BalanceRecordStub).of(br); } } } } if (nearestBalanceRecord.isNull) { return Optional!(long).empty(); } if (timestamp == nearestBalanceRecord.value.timestamp) { return Optional!(long).of(nearestBalanceRecord.value.value); } // Now that we have a balance record, work our way towards the desired // timestamp, applying journal entry changes. SysTime startTimestamp; SysTime endTimestamp; long balance = nearestBalanceRecord.value.value; if (timestamp > nearestBalanceRecord.value.timestamp) { startTimestamp = nearestBalanceRecord.value.timestamp; endTimestamp = timestamp; } else { startTimestamp = timestamp; endTimestamp = nearestBalanceRecord.value.timestamp; } auto relevantJournalEntries = journalEntries .filter!(je => ( je.accountId == accountId && je.timestamp >= startTimestamp && je.timestamp <= endTimestamp )); foreach (je; relevantJournalEntries) { long entryValue = je.amount; if (je.type == AccountJournalEntryType.CREDIT) { entryValue *= -1; } if (!je.accountType.debitsPositive) { entryValue *= -1; } if (je.timestamp < nearestBalanceRecord.value.timestamp) { entryValue *= -1; } balance += entryValue; } return Optional!(long).of(balance); }