Add category balance display.
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m45s Details

This commit is contained in:
Andrew Lalis 2026-02-13 11:26:43 -05:00
parent 6d1af2f46d
commit 1e35494f9d
7 changed files with 181 additions and 6 deletions

View File

@ -155,11 +155,14 @@ void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpRespo
@GetMapping(PROFILE_PATH ~ "/categories/:categoryId:ulong/balances")
void handleGetCategoryBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
response.status = HttpStatus.NOT_IMPLEMENTED;
// TODO: Add an API endpoint to provide a "balance" for the category.
// This would be the sum of credits and debits for all transactions set
// to this category or any child of it, over a specified interval, or for
// some default interval if none is provided.
bool includeChildren = request.getParamAs!bool("includeChildren", true);
// TODO: Support optional before/after timestamps to limit the scope.
auto balances = getCategoryBalances(
getProfileDataSource(request),
getCategoryId(request),
includeChildren
);
writeJsonBody(response, balances);
}
struct CategoryPayload {

View File

@ -28,6 +28,12 @@ interface TransactionCategoryRepository {
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
void deleteById(ulong id);
TransactionCategory updateById(ulong id, string name, string description, string color, Optional!ulong parentId);
TransactionCategoryBalance[] getBalance(
ulong id,
bool includeChildren,
Optional!SysTime afterTimestamp,
Optional!SysTime beforeTimestamp
);
}
interface TransactionTagRepository {

View File

@ -12,6 +12,7 @@ import util.sqlite;
import util.money;
import util.pagination;
import util.data;
import account.model;
class SqliteTransactionVendorRepository : TransactionVendorRepository {
private Database db;
@ -144,6 +145,86 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
return findById(id).orElseThrow();
}
TransactionCategoryBalance[] getBalance(
ulong id,
bool includeChildren,
Optional!SysTime afterTimestamp,
Optional!SysTime beforeTimestamp
) {
import std.algorithm : map;
import std.conv : to;
import std.string : join;
// First collect the list of IDs to include in the query.
ulong[] idsForQuery = [id];
if (includeChildren) {
import std.range : front, popFront;
ulong[] idQueue = [id];
while (idQueue.length > 0) {
ulong nextId = idQueue.front;
idQueue.popFront;
auto children = findAllByParentId(Optional!ulong.of(nextId));
foreach (child; children) {
idsForQuery ~= child.id;
idQueue ~= child.id;
}
}
}
const categoryIdsString = idsForQuery.map!(id => id.to!string).join(",");
// Now build the query, taking into account the optional timestamp constraints.
QueryBuilder qb = QueryBuilder("\"transaction\" txn")
.join("LEFT JOIN account_journal_entry je ON je.transaction_id = txn.id")
.select("je.currency")
.select("je.type")
.select("SUM(je.amount)")
.where("txn.category_id IN (" ~ categoryIdsString ~ ")");
if (!afterTimestamp.isNull) {
qb.where("txn.timestamp > ?")
.withArgBinding((ref stmt, ref idx) {
stmt.bind(idx++, afterTimestamp.value.toISOExtString());
});
}
if (!beforeTimestamp.isNull) {
qb.where("txn.timestamp < ?")
.withArgBinding((ref stmt, ref idx) {
stmt.bind(idx++, beforeTimestamp.value.toISOExtString());
});
}
string query = qb.build() ~ " ORDER BY je.currency ASC, je.type ASC";
Statement stmt = db.prepare(query);
qb.applyArgBindings(stmt);
ResultRange result = stmt.execute();
// Process the results into a set of category balances for each currency.
TransactionCategoryBalance[ushort] balancesGroupedByCurrency;
foreach (row; result) {
Currency currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(0));
string journalEntryType = row.peek!(string, PeekMode.slice)(1);
ulong amountSum = row.peek!ulong(2);
TransactionCategoryBalance balance;
if (currency.numericCode in balancesGroupedByCurrency) {
balance = balancesGroupedByCurrency[currency.numericCode];
} else {
balance = TransactionCategoryBalance(0, 0, 0, currency);
}
if (journalEntryType == AccountJournalEntryType.CREDIT) {
balance.credits = amountSum;
} else if (journalEntryType == AccountJournalEntryType.DEBIT) {
balance.debits = amountSum;
}
balancesGroupedByCurrency[currency.numericCode] = balance;
}
// Post-process into a list of balances for returning:
TransactionCategoryBalance[] balances = balancesGroupedByCurrency.values;
foreach (ref bal; balances) {
bal.balance = cast(long) bal.debits - cast(long) bal.credits;
}
return balances;
}
private static TransactionCategory parseCategory(Row row) {
import std.typecons;
return TransactionCategory(

View File

@ -147,3 +147,11 @@ struct TransactionCategoryResponse {
);
}
}
/// Structure representing the balance information for a category, for a given currency.
struct TransactionCategoryBalance {
ulong credits;
ulong debits;
long balance;
Currency currency;
}

View File

@ -348,6 +348,15 @@ TransactionCategoryResponse[] getChildCategories(ProfileDataSource ds, ulong cat
return categories.map!(c => TransactionCategoryResponse.of(c)).array;
}
TransactionCategoryBalance[] getCategoryBalances(ProfileDataSource ds, ulong categoryId, bool includeChildren) {
return ds.getTransactionCategoryRepository().getBalance(
categoryId,
includeChildren,
Optional!SysTime.empty(),
Optional!SysTime.empty()
);
}
TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
if (payload.name is null || payload.name.length == 0) {

View File

@ -32,6 +32,13 @@ export interface TransactionCategoryTree {
depth: number
}
export interface TransactionCategoryBalance {
credits: number
debits: number
balance: number
currency: Currency
}
export interface TransactionsListItem {
id: number
timestamp: string
@ -180,6 +187,10 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/categories/' + parentId + '/children')
}
getCategoryBalances(id: number): Promise<TransactionCategoryBalance[]> {
return super.getJson(this.path + '/categories/' + id + '/balances')
}
createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> {
return super.postJson(this.path + '/categories', data)
}

View File

@ -1,12 +1,17 @@
<script setup lang="ts">
import { formatMoney } from '@/api/data'
import { defaultPage, type Page, type PageRequest } from '@/api/pagination'
import { getSelectedProfile } from '@/api/profile'
import {
TransactionApiClient,
type TransactionCategoryBalance,
type TransactionCategory,
type TransactionsListItem,
} from '@/api/transaction'
import AppBadge from '@/components/common/AppBadge.vue'
import AppPage from '@/components/common/AppPage.vue'
import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'
import PaginationControls from '@/components/common/PaginationControls.vue'
import TransactionCard from '@/components/TransactionCard.vue'
import { onMounted, ref, watch } from 'vue'
@ -19,11 +24,21 @@ const category = ref<TransactionCategory | undefined>()
const parentCategory = ref<TransactionCategory | undefined>()
const childCategories = ref<TransactionCategory[]>([])
const relatedTransactionsPage = ref<Page<TransactionsListItem>>(defaultPage())
const balances = ref<TransactionCategoryBalance[]>([])
const balancesIncludeSubcategories = ref(true)
// Watch for changes to the route ID, and reload the category in that case.
watch(
() => route.params.id,
(newId) => loadCategory(parseInt(newId as string)),
)
// Watch for updates to balance input fields and refresh.
watch(balancesIncludeSubcategories, async () => {
if (!category.value) return
balances.value = await new TransactionApiClient(getSelectedProfile(route)).getCategoryBalances(
category.value.id,
)
})
onMounted(async () => {
const categoryId = parseInt(route.params.id as string)
@ -42,7 +57,7 @@ async function loadCategory(id: number) {
parentCategory.value = await api.getCategory(category.value.parentId)
}
childCategories.value = await api.getChildCategories(category.value.id)
console.log(childCategories.value)
balances.value = await api.getCategoryBalances(category.value.id)
await fetchPage(1)
} catch (err) {
console.error(err)
@ -117,6 +132,48 @@ async function fetchPage(pg: number) {
</ul>
</div>
<!-- Display total balances. -->
<div v-if="balances.length > 0">
<h3>Balances</h3>
<div
v-for="balance in balances"
:key="balance.currency.code"
>
USD:
<AppBadge
>Debits:
<span class="text-positive">{{ formatMoney(balance.debits, balance.currency) }}</span>
</AppBadge>
<AppBadge
>Credits:
<span class="text-negative">{{ formatMoney(balance.credits, balance.currency) }}</span>
</AppBadge>
<AppBadge
>Balance:
<span
:class="{ 'text-positive': balance.balance > 0, 'text-negative': balance.balance < 0 }"
>{{ formatMoney(balance.balance, balance.currency) }}</span
></AppBadge
>
</div>
<FormGroup>
<FormControl label="Include Subcategories">
<input
type="checkbox"
v-model="balancesIncludeSubcategories"
/>
</FormControl>
<!--
<FormControl label="After">
<input type="date" />
</FormControl>
<FormControl label="Before">
<input type="date" />
</FormControl>
-->
</FormGroup>
</div>
<div>
<h3 class="mb-0">Transactions</h3>
<p class="text-muted font-size-small mt-0">