Added total balances API endpoint.
Build and Deploy Web App / build-and-deploy (push) Successful in 18s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m13s Details

This commit is contained in:
andrewlalis 2025-09-03 22:02:34 -04:00
parent df4460d2ca
commit 25b715156b
5 changed files with 68 additions and 1 deletions

View File

@ -115,6 +115,12 @@ private void writeHistoryResponse(ref ServerHttpResponse response, in Page!Accou
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON); response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
} }
void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
auto balances = getTotalBalanceForAllAccounts(ds);
writeJsonBody(response, balances);
}
// Value records: // Value records:
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);

View File

@ -6,6 +6,7 @@ import std.datetime;
import account.model; import account.model;
import account.data; import account.data;
import profile.data; import profile.data;
import util.money;
/** /**
* Gets the balance for an account, at a given point in time. * Gets the balance for an account, at a given point in time.
@ -46,6 +47,39 @@ Optional!long getBalance(ProfileDataSource ds, ulong accountId, SysTime timestam
return Optional!long.empty; 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 * 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 * value record, and all journal entries between that record and the desired

View File

@ -51,6 +51,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
import account.api; import account.api;
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount); a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/account-balances", &handleGetTotalBalances);
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong"; const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount); a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount); a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);

View File

@ -123,11 +123,18 @@ export interface AccountHistoryJournalEntryItem extends AccountHistoryItem {
transactionDescription: string transactionDescription: string
} }
export interface CurrencyBalance {
currency: Currency
balance: number
}
export class AccountApiClient extends ApiClient { export class AccountApiClient extends ApiClient {
readonly path: string readonly path: string
readonly profileName: string
constructor(route: RouteLocation) { constructor(route: RouteLocation) {
super() super()
this.profileName = getSelectedProfile(route)
this.path = `/profiles/${getSelectedProfile(route)}/accounts` this.path = `/profiles/${getSelectedProfile(route)}/accounts`
} }
@ -155,6 +162,10 @@ export class AccountApiClient extends ApiClient {
return super.getJsonPage(`${this.path}/${id}/history`, pageRequest) return super.getJsonPage(`${this.path}/${id}/history`, pageRequest)
} }
getTotalBalances(): Promise<CurrencyBalance[]> {
return super.getJson(`/profiles/${this.profileName}/account-balances`)
}
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> { getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest) return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
} }

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account' import { AccountApiClient, type Account, type CurrencyBalance } from '@/api/account'
import { formatMoney } from '@/api/data' import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile' import { getSelectedProfile } from '@/api/profile'
import AppButton from '@/components/AppButton.vue' import AppButton from '@/components/AppButton.vue'
@ -11,11 +11,16 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const accounts: Ref<Account[]> = ref([]) const accounts: Ref<Account[]> = ref([])
const totalBalances: Ref<CurrencyBalance[]> = ref([])
onMounted(async () => { onMounted(async () => {
const accountApi = new AccountApiClient(route) const accountApi = new AccountApiClient(route)
accountApi.getAccounts().then(result => accounts.value = result) accountApi.getAccounts().then(result => accounts.value = result)
.catch(err => console.error(err)) .catch(err => console.error(err))
accountApi.getTotalBalances().then(result => {
totalBalances.value = result
})
.catch(err => console.error(err))
}) })
</script> </script>
<template> <template>
@ -51,6 +56,16 @@ onMounted(async () => {
<p v-if="accounts.length === 0"> <p v-if="accounts.length === 0">
You haven't added any accounts. Add one to start tracking your finances. You haven't added any accounts. Add one to start tracking your finances.
</p> </p>
<div>
<ul>
<li v-for="bal in totalBalances" :key="bal.currency.code">
<span>Total in {{ bal.currency.code }}</span>
=
<span>{{ formatMoney(bal.balance, bal.currency) }}</span>
</li>
</ul>
</div>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account <AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account