Add BQL-based report endpoints for expenses and contributions
New endpoints: - GET /api/v1/reports/expenses - Expense summary by account or month - GET /api/v1/reports/contributions - User contribution totals New FavaClient methods: - get_expense_summary_bql() - Aggregates expenses with date filtering - get_user_contributions_bql() - Aggregates user expense submissions Both use sum(weight) for efficient SATS aggregation from price notation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
addf4cd05f
commit
7dabe8700d
2 changed files with 291 additions and 0 deletions
162
fava_client.py
162
fava_client.py
|
|
@ -778,6 +778,168 @@ class FavaClient:
|
||||||
|
|
||||||
return list(user_data.values())
|
return list(user_data.values())
|
||||||
|
|
||||||
|
async def get_expense_summary_bql(
|
||||||
|
self,
|
||||||
|
start_date: str = None,
|
||||||
|
end_date: str = None,
|
||||||
|
group_by: str = "account"
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get expense summary using BQL, grouped by account or date.
|
||||||
|
|
||||||
|
Uses sum(weight) for efficient SATS aggregation from price notation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: ISO format date string (YYYY-MM-DD), optional
|
||||||
|
end_date: ISO format date string (YYYY-MM-DD), optional
|
||||||
|
group_by: "account" (default) or "month" for grouping
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of expense summaries:
|
||||||
|
[
|
||||||
|
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
|
||||||
|
{"account": "Expenses:Supplies:Kitchen", "fiat": 200.00, "sats": 220000},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
Or if group_by="month":
|
||||||
|
[
|
||||||
|
{"month": "2025-12", "fiat": 700.00, "sats": 770000},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Build date filter
|
||||||
|
date_filter = ""
|
||||||
|
if start_date:
|
||||||
|
date_filter += f" AND date >= {start_date}"
|
||||||
|
if end_date:
|
||||||
|
date_filter += f" AND date <= {end_date}"
|
||||||
|
|
||||||
|
if group_by == "month":
|
||||||
|
query = f"""
|
||||||
|
SELECT year, month, sum(number), sum(weight)
|
||||||
|
WHERE account ~ 'Expenses:'
|
||||||
|
AND flag = '*'
|
||||||
|
{date_filter}
|
||||||
|
GROUP BY year, month
|
||||||
|
ORDER BY year DESC, month DESC
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
query = f"""
|
||||||
|
SELECT account, sum(number), sum(weight)
|
||||||
|
WHERE account ~ 'Expenses:'
|
||||||
|
AND flag = '*'
|
||||||
|
{date_filter}
|
||||||
|
GROUP BY account
|
||||||
|
ORDER BY sum(weight)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.query_bql(query)
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for row in result["rows"]:
|
||||||
|
if group_by == "month":
|
||||||
|
year, month, fiat_sum, weight_sum = row
|
||||||
|
# Parse SATS from weight
|
||||||
|
sats_amount = 0
|
||||||
|
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||||
|
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||||
|
|
||||||
|
summaries.append({
|
||||||
|
"month": f"{year}-{month:02d}",
|
||||||
|
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"sats": sats_amount
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
account, fiat_sum, weight_sum = row
|
||||||
|
# Parse SATS from weight
|
||||||
|
sats_amount = 0
|
||||||
|
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||||
|
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||||
|
|
||||||
|
summaries.append({
|
||||||
|
"account": account,
|
||||||
|
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"sats": sats_amount
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"BQL: Expense summary returned {len(summaries)} items")
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting expense summary via BQL: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_user_contributions_bql(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get total expense contributions per user using BQL.
|
||||||
|
|
||||||
|
Uses sum(weight) to aggregate all expenses each user has submitted
|
||||||
|
that created liabilities (castle owes user).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user contribution summaries:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"user_id": "cfe378b3",
|
||||||
|
"total_fiat": 1500.00,
|
||||||
|
"total_sats": 1650000,
|
||||||
|
"entry_count": 25
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Query all expense entries that created payables, grouped by user
|
||||||
|
query = """
|
||||||
|
SELECT account, sum(number), sum(weight), count(number)
|
||||||
|
WHERE account ~ 'Liabilities:Payable:User-'
|
||||||
|
AND 'expense-entry' IN tags
|
||||||
|
AND flag = '*'
|
||||||
|
GROUP BY account
|
||||||
|
ORDER BY sum(weight)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.query_bql(query)
|
||||||
|
|
||||||
|
contributions = []
|
||||||
|
for row in result["rows"]:
|
||||||
|
account, fiat_sum, weight_sum, count = row
|
||||||
|
|
||||||
|
# Extract user_id from account name
|
||||||
|
if ":User-" not in account:
|
||||||
|
continue
|
||||||
|
user_id = account.split(":User-")[1][:8]
|
||||||
|
|
||||||
|
# Parse SATS from weight (negative for liabilities)
|
||||||
|
sats_amount = 0
|
||||||
|
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||||
|
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||||
|
|
||||||
|
contributions.append({
|
||||||
|
"user_id": user_id,
|
||||||
|
"total_fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"total_sats": sats_amount,
|
||||||
|
"entry_count": int(count) if count else 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by total_sats descending (highest contributors first)
|
||||||
|
contributions.sort(key=lambda x: x["total_sats"], reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"BQL: Found contributions from {len(contributions)} users")
|
||||||
|
return contributions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user contributions via BQL: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_account_transactions(
|
async def get_account_transactions(
|
||||||
self,
|
self,
|
||||||
account_name: str,
|
account_name: str,
|
||||||
|
|
|
||||||
129
views_api.py
129
views_api.py
|
|
@ -2215,6 +2215,135 @@ async def api_get_castle_users(
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@castle_api_router.get("/api/v1/reports/expenses")
|
||||||
|
async def api_expense_report(
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
group_by: str = "account",
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get expense summary report using BQL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Filter from this date (YYYY-MM-DD), optional
|
||||||
|
end_date: Filter to this date (YYYY-MM-DD), optional
|
||||||
|
group_by: "account" (by expense category) or "month" (by month)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"total_fiat": 1500.00,
|
||||||
|
"total_sats": 1650000,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"group_by": "account",
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-12-31"
|
||||||
|
}
|
||||||
|
|
||||||
|
Admin only.
|
||||||
|
"""
|
||||||
|
from .fava_client import get_fava_client
|
||||||
|
|
||||||
|
if group_by not in ["account", "month"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="group_by must be 'account' or 'month'"
|
||||||
|
)
|
||||||
|
|
||||||
|
fava = get_fava_client()
|
||||||
|
summaries = await fava.get_expense_summary_bql(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
group_by=group_by
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_fiat = sum(s.get("fiat", 0) for s in summaries)
|
||||||
|
total_sats = sum(s.get("sats", 0) for s in summaries)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summaries,
|
||||||
|
"total_fiat": total_fiat,
|
||||||
|
"total_sats": total_sats,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"group_by": group_by,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"count": len(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@castle_api_router.get("/api/v1/reports/contributions")
|
||||||
|
async def api_contributions_report(
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get user contribution report using BQL.
|
||||||
|
|
||||||
|
Shows total expenses submitted by each user (creating payables).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"user_id": "cfe378b3",
|
||||||
|
"username": "alice",
|
||||||
|
"total_fiat": 1500.00,
|
||||||
|
"total_sats": 1650000,
|
||||||
|
"entry_count": 25
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"total_fiat": 5000.00,
|
||||||
|
"total_sats": 5500000,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"user_count": 5
|
||||||
|
}
|
||||||
|
|
||||||
|
Admin only.
|
||||||
|
"""
|
||||||
|
from lnbits.core.crud.users import get_user
|
||||||
|
from .fava_client import get_fava_client
|
||||||
|
|
||||||
|
fava = get_fava_client()
|
||||||
|
contributions = await fava.get_user_contributions_bql()
|
||||||
|
|
||||||
|
# Enrich with usernames
|
||||||
|
for contrib in contributions:
|
||||||
|
user_id = contrib["user_id"]
|
||||||
|
# Try to find full user_id from wallet settings
|
||||||
|
settings = await get_all_user_wallet_settings()
|
||||||
|
full_user_id = None
|
||||||
|
for s in settings:
|
||||||
|
if s.id.startswith(user_id):
|
||||||
|
full_user_id = s.id
|
||||||
|
break
|
||||||
|
|
||||||
|
if full_user_id:
|
||||||
|
user = await get_user(full_user_id)
|
||||||
|
contrib["username"] = user.username if user and user.username else None
|
||||||
|
contrib["full_user_id"] = full_user_id
|
||||||
|
else:
|
||||||
|
contrib["username"] = None
|
||||||
|
contrib["full_user_id"] = None
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
|
||||||
|
total_sats = sum(c.get("total_sats", 0) for c in contributions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contributions": contributions,
|
||||||
|
"total_fiat": total_fiat,
|
||||||
|
"total_sats": total_sats,
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"user_count": len(contributions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries")
|
@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries")
|
||||||
async def api_get_unsettled_entries(
|
async def api_get_unsettled_entries(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue