finnow/finnow-api/source/account/service.d

333 lines
12 KiB
D

module account.service;
import handy_http_primitives;
import std.datetime;
import account.model;
import account.data;
import profile.data;
import util.money;
/**
* Gets the balance for an account, at a given point in time.
* Params:
* ds = The data source for fetching account data.
* accountId = The account's id.
* timestamp = The timestamp at which to determine the account's balance.
* Returns: An optional that resolves to the account's balance if it can be
* determined from value records and journal entries.
*/
Optional!long getBalance(ProfileDataSource ds, ulong accountId, SysTime timestamp = Clock.currTime(UTC())) {
auto accountRepo = ds.getAccountRepository();
auto valueRecordRepo = ds.getAccountValueRecordRepository();
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
Account account = accountRepo.findById(accountId).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
Optional!AccountValueRecord nearestBefore = valueRecordRepo.findNearestByAccountIdBefore(accountId, timestamp);
Optional!AccountValueRecord nearestAfter = valueRecordRepo.findNearestByAccountIdAfter(accountId, timestamp);
if (nearestBefore.isNull && nearestAfter.isNull) return Optional!long.empty;
if (!nearestBefore.isNull && !nearestAfter.isNull) {
Duration timeFromBefore = timestamp - nearestBefore.value.timestamp;
Duration timeToAfter = nearestAfter.value.timestamp - timestamp;
if (timeFromBefore <= timeToAfter) {
return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
} else {
return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
}
}
if (!nearestBefore.isNull) {
return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
}
if (!nearestAfter.isNull) {
return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
}
return Optional!long.empty;
}
struct CurrencyBalance {
Currency currency;
long balance;
}
CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime timestamp = Clock.currTime(UTC())) {
auto accountRepo = ds.getAccountRepository();
CurrencyBalance[] balances;
foreach (Account account; accountRepo.findAll()) {
Optional!long accountBalance = getBalance(ds, account.id, timestamp);
if (!accountBalance.isNull) {
long value = accountBalance.value;
if (!account.type.debitsPositive) {
value = -value;
}
// Add the balance to the relevant currency balance:
bool added = false;
foreach (ref cb; balances) {
if (cb.currency.code == account.currency.code) {
cb.balance += value;
added = true;
break;
}
}
if (!added) {
balances ~= CurrencyBalance(account.currency, value);
}
}
}
return balances;
}
/**
* Helper method that derives a balance for an account, by using the nearest
* value record, and all journal entries between that record and the desired
* timestamp. We start at the value record's balance, then apply each journal
* entry's amount to that balance (either credit or debit) to arrive at the
* final balance.
* Params:
* account = The account to derive a balance for.
* valueRecord = The nearest value record to use.
* timestamp = The target timestamp at which to determine the balance.
* journalEntryRepo = A repository for fetching journal entries.
* Returns: The derived balance.
*/
private long deriveBalance(
Account account,
AccountValueRecord valueRecord,
SysTime timestamp,
AccountJournalEntryRepository journalEntryRepo
) {
enum TimeDirection {
Forward,
Backward
}
SysTime startTimestamp;
SysTime endTimestamp;
TimeDirection timeDirection;
if (valueRecord.timestamp < timestamp) {
startTimestamp = valueRecord.timestamp;
endTimestamp = timestamp;
timeDirection = TimeDirection.Forward;
} else {
startTimestamp = timestamp;
endTimestamp = valueRecord.timestamp;
timeDirection = TimeDirection.Backward;
}
long balance = valueRecord.value;
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp, account.id);
foreach (entry; journalEntries) {
long entryValue = entry.amount;
if (entry.type == AccountJournalEntryType.CREDIT) {
entryValue *= -1;
}
if (!account.type.debitsPositive) {
entryValue *= -1;
}
if (timeDirection == TimeDirection.Backward) {
entryValue *= -1;
}
balance += entryValue;
}
return balance;
}
unittest {
import std.algorithm;
import std.array;
import util.money;
class MockAccountRepository : TestAccountRepositoryStub {
private Account account;
this(Account account) {
this.account = account;
}
override Optional!Account findById(ulong id) {
if (id == this.account.id) return Optional!Account.of(account);
return Optional!Account.empty;
}
}
class MockValueRecordRepository : TestAccountValueRecordRepositoryStub {
private AccountValueRecord[] valueRecords;
this(AccountValueRecord[] valueRecords) {
this.valueRecords = valueRecords;
}
override Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
auto matches = valueRecords
.filter!(v => v.accountId == accountId)
.filter!(v => v.timestamp < timestamp)
.array;
matches.sort!((a, b) => a.timestamp < b.timestamp);
if (matches.length == 0) return Optional!AccountValueRecord.empty;
return Optional!AccountValueRecord.of(matches[$-1]);
}
override Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
auto matches = valueRecords
.filter!(v => v.accountId == accountId)
.filter!(v => v.timestamp > timestamp)
.array;
matches.sort!((a, b) => a.timestamp < b.timestamp);
if (matches.length == 0) return Optional!AccountValueRecord.empty;
return Optional!AccountValueRecord.of(matches[0]);
}
}
class MockJournalEntryRepository : TestAccountJournalEntryRepositoryStub {
private AccountJournalEntry[] journalEntries;
this(AccountJournalEntry[] journalEntries) {
this.journalEntries = journalEntries;
}
override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl)
.array;
matches.sort!((a, b) => a.timestamp < b.timestamp);
return matches;
}
}
class MockDataSource : TestProfileDataSourceStub {
private Account account;
private AccountValueRecord[] valueRecords;
private AccountJournalEntry[] journalEntries;
this(Account account, AccountValueRecord[] valueRecords, AccountJournalEntry[] journalEntries) {
this.account = account;
this.valueRecords = valueRecords;
this.journalEntries = journalEntries;
}
override AccountRepository getAccountRepository() {
return new MockAccountRepository(account);
}
override AccountValueRecordRepository getAccountValueRecordRepository() {
return new MockValueRecordRepository(valueRecords);
}
override AccountJournalEntryRepository getAccountJournalEntryRepository() {
return new MockJournalEntryRepository(journalEntries);
}
}
SysTime NOW = SysTime(DateTime(2025, 8, 18, 15, 30, 59), UTC());
Account sampleAccount = Account(
1,
NOW - days(30),
false,
AccountTypes.CHECKING,
"1234",
"Sample Checking Account",
Currencies.USD,
"This is a sample testing account."
);
AccountValueRecord makeSampleValueRecord(SysTime timestamp, long value) {
return AccountValueRecord(
1,
timestamp,
sampleAccount.id,
AccountValueRecordType.BALANCE,
value,
sampleAccount.currency
);
}
AccountJournalEntry makeSampleCreditEntry(SysTime timestamp, ulong amount) {
return AccountJournalEntry(
1,
timestamp,
sampleAccount.id,
1,
amount,
AccountJournalEntryType.CREDIT,
sampleAccount.currency
);
}
AccountJournalEntry makeSampleDebitEntry(SysTime timestamp, ulong amount) {
return AccountJournalEntry(
1,
timestamp,
sampleAccount.id,
1,
amount,
AccountJournalEntryType.DEBIT,
sampleAccount.currency
);
}
// 1. Account exists, but no value records or journal entries.
ProfileDataSource ds1 = new MockDataSource(sampleAccount, [], []);
assert(getBalance(ds1, sampleAccount.id).isNull);
// 2a. Single value record exists, so that's the value of the account.
ProfileDataSource ds2a = new MockDataSource(
sampleAccount,
[makeSampleValueRecord(NOW - days(5), 123)],
[]
);
assert(getBalance(ds2a, sampleAccount.id, NOW).value == 123);
// 2b. Same as the previous, but a value record exists after the timestamp.
ProfileDataSource ds2b = new MockDataSource(
sampleAccount,
[makeSampleValueRecord(NOW + days(5), 123)],
[]
);
assert(getBalance(ds2b, sampleAccount.id, NOW).value == 123);
// 3. If there are balance records both before and after the timestamp, the closest one is used.
ProfileDataSource ds3 = new MockDataSource(
sampleAccount,
[
makeSampleValueRecord(NOW - days(5), 123),
makeSampleValueRecord(NOW + days(3), -25)
],
[]
);
assert(getBalance(ds3, sampleAccount.id, NOW).value == -25);
assert(getBalance(ds3, sampleAccount.id, NOW - days(2)).value == 123);
// 4. Balance record followed by some entries, as a basic balance derivation.
ProfileDataSource ds4 = new MockDataSource(
sampleAccount,
[
makeSampleValueRecord(NOW - days(10), 100)
],
[
makeSampleDebitEntry(NOW - days(9), 25), // 100 + 25 = 125
makeSampleCreditEntry(NOW - days(8), 5), // 125 - 5 = 120
makeSampleDebitEntry(NOW - days(5), 80), // 120 + 80 = 200
makeSampleCreditEntry(NOW - days(1), 150) // 200 - 150 = 50
]
);
assert(getBalance(ds4, sampleAccount.id, NOW).value == 50);
assert(getBalance(ds4, sampleAccount.id, NOW - days(2)).value == 200);
// 5. Balance record in the future, with some entries between it and now, for backwards derivation.
ProfileDataSource ds5 = new MockDataSource(
sampleAccount,
[
makeSampleValueRecord(NOW + days(10), 100)
],
[
makeSampleCreditEntry(NOW + days(9), 200), // 100 - (-200) = 300
makeSampleDebitEntry(NOW + days(8), 50), // 300 - 50 = 250
makeSampleDebitEntry(NOW + days(2), 25) // 250 - 25 = 225
]
);
assert(getBalance(ds5, sampleAccount.id, NOW).value == 225);
assert(getBalance(ds5, sampleAccount.id, NOW + days(4)).value == 250);
}