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

147 lines
4.8 KiB
D

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