333 lines
12 KiB
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, ulong accountId) {
|
|
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);
|
|
}
|