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")
|
@GetMapping(PROFILE_PATH ~ "/categories/:categoryId:ulong/balances")
|
||||||
void handleGetCategoryBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetCategoryBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
response.status = HttpStatus.NOT_IMPLEMENTED;
|
bool includeChildren = request.getParamAs!bool("includeChildren", true);
|
||||||
// TODO: Add an API endpoint to provide a "balance" for the category.
|
// TODO: Support optional before/after timestamps to limit the scope.
|
||||||
// This would be the sum of credits and debits for all transactions set
|
auto balances = getCategoryBalances(
|
||||||
// to this category or any child of it, over a specified interval, or for
|
getProfileDataSource(request),
|
||||||
// some default interval if none is provided.
|
getCategoryId(request),
|
||||||
|
includeChildren
|
||||||
|
);
|
||||||
|
writeJsonBody(response, balances);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CategoryPayload {
|
struct CategoryPayload {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ interface TransactionCategoryRepository {
|
||||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
TransactionCategory updateById(ulong id, string name, string description, string color, Optional!ulong parentId);
|
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 {
|
interface TransactionTagRepository {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import util.sqlite;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
import account.model;
|
||||||
|
|
||||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
|
@ -144,6 +145,86 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
return findById(id).orElseThrow();
|
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) {
|
private static TransactionCategory parseCategory(Row row) {
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
return TransactionCategory(
|
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;
|
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) {
|
TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
|
||||||
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||||
if (payload.name is null || payload.name.length == 0) {
|
if (payload.name is null || payload.name.length == 0) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ export interface TransactionCategoryTree {
|
||||||
depth: number
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionCategoryBalance {
|
||||||
|
credits: number
|
||||||
|
debits: number
|
||||||
|
balance: number
|
||||||
|
currency: Currency
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransactionsListItem {
|
export interface TransactionsListItem {
|
||||||
id: number
|
id: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
|
@ -180,6 +187,10 @@ export class TransactionApiClient extends ApiClient {
|
||||||
return super.getJson(this.path + '/categories/' + parentId + '/children')
|
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> {
|
createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> {
|
||||||
return super.postJson(this.path + '/categories', data)
|
return super.postJson(this.path + '/categories', data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatMoney } from '@/api/data'
|
||||||
import { defaultPage, type Page, type PageRequest } from '@/api/pagination'
|
import { defaultPage, type Page, type PageRequest } from '@/api/pagination'
|
||||||
import { getSelectedProfile } from '@/api/profile'
|
import { getSelectedProfile } from '@/api/profile'
|
||||||
import {
|
import {
|
||||||
TransactionApiClient,
|
TransactionApiClient,
|
||||||
|
type TransactionCategoryBalance,
|
||||||
type TransactionCategory,
|
type TransactionCategory,
|
||||||
type TransactionsListItem,
|
type TransactionsListItem,
|
||||||
} from '@/api/transaction'
|
} from '@/api/transaction'
|
||||||
|
import AppBadge from '@/components/common/AppBadge.vue'
|
||||||
import AppPage from '@/components/common/AppPage.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 PaginationControls from '@/components/common/PaginationControls.vue'
|
||||||
import TransactionCard from '@/components/TransactionCard.vue'
|
import TransactionCard from '@/components/TransactionCard.vue'
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
|
@ -19,11 +24,21 @@ const category = ref<TransactionCategory | undefined>()
|
||||||
const parentCategory = ref<TransactionCategory | undefined>()
|
const parentCategory = ref<TransactionCategory | undefined>()
|
||||||
const childCategories = ref<TransactionCategory[]>([])
|
const childCategories = ref<TransactionCategory[]>([])
|
||||||
const relatedTransactionsPage = ref<Page<TransactionsListItem>>(defaultPage())
|
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(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
(newId) => loadCategory(parseInt(newId as string)),
|
(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 () => {
|
onMounted(async () => {
|
||||||
const categoryId = parseInt(route.params.id as string)
|
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)
|
parentCategory.value = await api.getCategory(category.value.parentId)
|
||||||
}
|
}
|
||||||
childCategories.value = await api.getChildCategories(category.value.id)
|
childCategories.value = await api.getChildCategories(category.value.id)
|
||||||
console.log(childCategories.value)
|
balances.value = await api.getCategoryBalances(category.value.id)
|
||||||
await fetchPage(1)
|
await fetchPage(1)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -117,6 +132,48 @@ async function fetchPage(pg: number) {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<h3 class="mb-0">Transactions</h3>
|
<h3 class="mb-0">Transactions</h3>
|
||||||
<p class="text-muted font-size-small mt-0">
|
<p class="text-muted font-size-small mt-0">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue