module account.service; import handy_http_primitives; import std.datetime; import account.model; import account.data; import profile.data; /** * 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; } /** * 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); 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); }