Add receivable/payable filtering with database-level query optimization

Add account type filtering to Recent Transactions table and fix pagination issue where filters were applied after fetching results, causing incomplete data display.

Database layer (crud.py):
  - Add get_journal_entries_by_user_and_account_type() to filter entries by
    both user_id and account_type at SQL query level
  - Add count_journal_entries_by_user_and_account_type() for accurate counts
  - Filters apply before pagination, ensuring all matching records are fetched

API layer (views_api.py):
  - Add filter_account_type parameter ('asset' for receivable, 'liability' for payable)
  - Refactor filtering logic to use new database-level filter functions
  - Support filter combinations: user only, account_type only, user+account_type, or all
  - Enrich entries with account_type metadata for UI display

Frontend (index.js):
  - Add account_type to transactionFilter state
  - Add accountTypeOptions computed property with receivable/payable choices
  - Reorder table columns to show User before Date
  - Update loadTransactions to send account_type filter parameter
  - Update clearTransactionFilter to clear both user and account_type filters

UI (index.html):
  - Add second filter dropdown for account type (Receivable/Payable)
  - Show clear button when either filter is active
  - Update button label from "Clear Filter" to "Clear Filters"

This fixes the critical bug where filtering for receivables would only show a subset of results (e.g., 2 out of 20 entries fetched) instead of all matching receivables. Now filters are applied at the database level before pagination, ensuring users see all relevant transactions.
This commit is contained in:
padreug 2025-11-09 00:28:54 +01:00
parent f3d0d8652b
commit 3af93c3479
4 changed files with 163 additions and 22 deletions

86
crud.py
View file

@ -395,6 +395,92 @@ async def count_journal_entries_by_user(user_id: str) -> int:
return result["total"] if result else 0
async def get_journal_entries_by_user_and_account_type(
user_id: str, account_type: str, limit: int = 100, offset: int = 0
) -> list[JournalEntry]:
"""Get journal entries that affect the user's accounts filtered by account type"""
# Get all user-specific accounts of the specified type
user_accounts = await db.fetchall(
"SELECT id FROM accounts WHERE user_id = :user_id AND account_type = :account_type",
{"user_id": user_id, "account_type": account_type},
)
if not user_accounts:
return []
account_ids = [acc["id"] for acc in user_accounts]
# Get all journal entries that have lines affecting these accounts
placeholders = ','.join([f":account_{i}" for i in range(len(account_ids))])
params = {f"account_{i}": acc_id for i, acc_id in enumerate(account_ids)}
params["limit"] = limit
params["offset"] = offset
entries_data = await db.fetchall(
f"""
SELECT DISTINCT je.*
FROM journal_entries je
JOIN entry_lines el ON je.id = el.journal_entry_id
WHERE el.account_id IN ({placeholders})
ORDER BY je.entry_date DESC, je.created_at DESC
LIMIT :limit OFFSET :offset
""",
params,
)
entries = []
for entry_data in entries_data:
# Parse flag and meta from database
from .models import JournalEntryFlag
flag = JournalEntryFlag(entry_data.get("flag", "*"))
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
entry = JournalEntry(
id=entry_data["id"],
description=entry_data["description"],
entry_date=entry_data["entry_date"],
created_by=entry_data["created_by"],
created_at=entry_data["created_at"],
reference=entry_data["reference"],
flag=flag,
meta=meta,
lines=[],
)
entry.lines = await get_entry_lines(entry.id)
entries.append(entry)
return entries
async def count_journal_entries_by_user_and_account_type(user_id: str, account_type: str) -> int:
"""Count journal entries that affect the user's accounts filtered by account type"""
# Get all user-specific accounts of the specified type
user_accounts = await db.fetchall(
"SELECT id FROM accounts WHERE user_id = :user_id AND account_type = :account_type",
{"user_id": user_id, "account_type": account_type},
)
if not user_accounts:
return 0
account_ids = [acc["id"] for acc in user_accounts]
# Count journal entries that have lines affecting these accounts
placeholders = ','.join([f":account_{i}" for i in range(len(account_ids))])
params = {f"account_{i}": acc_id for i, acc_id in enumerate(account_ids)}
result = await db.fetchone(
f"""
SELECT COUNT(DISTINCT je.id) as total
FROM journal_entries je
JOIN entry_lines el ON je.id = el.journal_entry_id
WHERE el.account_id IN ({placeholders})
""",
params,
)
return result["total"] if result else 0
# ===== BALANCE AND REPORTING =====

View file

@ -18,7 +18,8 @@ window.app = Vue.createApp({
has_prev: false
},
transactionFilter: {
user_id: null // For filtering by user
user_id: null, // For filtering by user
account_type: null // For filtering by receivable/payable (asset/liability)
},
accounts: [],
currencies: [],
@ -189,14 +190,21 @@ window.app = Vue.createApp({
transactionColumns() {
return [
{ name: 'flag', label: 'Status', field: 'flag', align: 'left', sortable: true },
{ name: 'username', label: 'User', field: 'username', align: 'left', sortable: true },
{ name: 'date', label: 'Date', field: 'entry_date', align: 'left', sortable: true },
{ name: 'description', label: 'Description', field: 'description', align: 'left', sortable: false },
{ name: 'username', label: 'User', field: 'username', align: 'left', sortable: true },
{ name: 'amount', label: 'Amount (sats)', field: 'amount', align: 'right', sortable: false },
{ name: 'fiat', label: 'Fiat Amount', field: 'fiat', align: 'right', sortable: false },
{ name: 'reference', label: 'Reference', field: 'reference', align: 'left', sortable: false }
]
},
accountTypeOptions() {
return [
{ label: 'All Types', value: null },
{ label: 'Receivable (User owes Castle)', value: 'asset' },
{ label: 'Payable (Castle owes User)', value: 'liability' }
]
},
expenseAccounts() {
return this.accounts.filter(a => a.account_type === 'expense')
},
@ -344,11 +352,14 @@ window.app = Vue.createApp({
const limit = parseInt(this.transactionPagination.limit) || 20
// Build query params with filter
// Build query params with filters
let queryParams = `limit=${limit}&offset=${currentOffset}`
if (this.transactionFilter.user_id) {
queryParams += `&filter_user_id=${this.transactionFilter.user_id}`
}
if (this.transactionFilter.account_type) {
queryParams += `&filter_account_type=${this.transactionFilter.account_type}`
}
const response = await LNbits.api.request(
'GET',
@ -373,6 +384,7 @@ window.app = Vue.createApp({
},
clearTransactionFilter() {
this.transactionFilter.user_id = null
this.transactionFilter.account_type = null
this.transactionPagination.offset = 0
this.loadTransactions(0)
},

View file

@ -357,12 +357,31 @@
</template>
</q-select>
</div>
<div class="col-auto" v-if="transactionFilter.user_id">
<div class="col-auto" style="min-width: 250px;">
<q-select
v-model="transactionFilter.account_type"
:options="accountTypeOptions"
option-value="value"
option-label="label"
emit-value
map-options
clearable
label="Filter by Type"
dense
outlined
@update:model-value="applyTransactionFilter"
>
<template v-slot:prepend>
<q-icon name="account_balance" />
</template>
</q-select>
</div>
<div class="col-auto" v-if="transactionFilter.user_id || transactionFilter.account_type">
<q-btn
flat
dense
icon="clear"
label="Clear Filter"
label="Clear Filters"
@click="clearTransactionFilter"
/>
</div>

View file

@ -268,30 +268,51 @@ async def api_get_user_entries(
limit: int = 20,
offset: int = 0,
filter_user_id: str = None,
filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable
) -> dict:
"""Get journal entries that affect the current user's accounts"""
from lnbits.settings import settings as lnbits_settings
from lnbits.core.crud.users import get_user
from .crud import count_all_journal_entries, count_journal_entries_by_user, get_account
from .crud import (
count_all_journal_entries,
count_journal_entries_by_user,
count_journal_entries_by_user_and_account_type,
get_account,
get_journal_entries_by_user_and_account_type,
)
# If super user, show all journal entries
# Determine which entries to fetch based on filters
if wallet.wallet.user == lnbits_settings.super_user:
entries = await get_all_journal_entries(limit, offset)
total = await count_all_journal_entries()
# Super user with user_id filter
if filter_user_id:
# Filter by both user_id and account_type
if filter_account_type:
entries = await get_journal_entries_by_user_and_account_type(
filter_user_id, filter_account_type, limit, offset
)
total = await count_journal_entries_by_user_and_account_type(
filter_user_id, filter_account_type
)
else:
# Filter by user_id only
entries = await get_journal_entries_by_user(filter_user_id, limit, offset)
total = await count_journal_entries_by_user(filter_user_id)
else:
# No user filter, show all entries (account_type filter not supported for all entries)
entries = await get_all_journal_entries(limit, offset)
total = await count_all_journal_entries()
else:
entries = await get_journal_entries_by_user(wallet.wallet.user, limit, offset)
total = await count_journal_entries_by_user(wallet.wallet.user)
# Filter by user_id if specified (super user only)
if filter_user_id and wallet.wallet.user == lnbits_settings.super_user:
entries = [e for e in entries if any(
line.account_id in [acc["id"] for acc in await db.fetchall(
"SELECT id FROM accounts WHERE user_id = :user_id",
{"user_id": filter_user_id}
)]
for line in e.lines
)]
total = len(entries)
# Regular user
if filter_account_type:
entries = await get_journal_entries_by_user_and_account_type(
wallet.wallet.user, filter_account_type, limit, offset
)
total = await count_journal_entries_by_user_and_account_type(
wallet.wallet.user, filter_account_type
)
else:
entries = await get_journal_entries_by_user(wallet.wallet.user, limit, offset)
total = await count_journal_entries_by_user(wallet.wallet.user)
# Enrich entries with username information
enriched_entries = []
@ -299,11 +320,13 @@ async def api_get_user_entries(
# Find user_id from entry lines (look for user-specific accounts)
entry_user_id = None
entry_username = None
entry_account_type = None
for line in entry.lines:
account = await get_account(line.account_id)
if account and account.user_id:
entry_user_id = account.user_id
entry_account_type = account.account_type.value if hasattr(account.account_type, 'value') else account.account_type
user = await get_user(account.user_id)
entry_username = user.username if user and user.username else account.user_id[:16] + "..."
break
@ -312,6 +335,7 @@ async def api_get_user_entries(
**entry.dict(),
"user_id": entry_user_id,
"username": entry_username,
"account_type": entry_account_type,
})
return {