Add category balance display.
This commit is contained in:
parent
6d1af2f46d
commit
1e35494f9d
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue