From 7dabe8700d3e5f6d7467a8a94a3836b3400b8afa Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:15:29 +0100 Subject: [PATCH] Add BQL-based report endpoints for expenses and contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fava_client.py | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 129 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) diff --git a/fava_client.py b/fava_client.py index 1b426b6..f7f9983 100644 --- a/fava_client.py +++ b/fava_client.py @@ -778,6 +778,168 @@ class FavaClient: 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( self, account_name: str, diff --git a/views_api.py b/views_api.py index 7589d54..4908cda 100644 --- a/views_api.py +++ b/views_api.py @@ -2215,6 +2215,135 @@ async def api_get_castle_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") async def api_get_unsettled_entries( user_id: str,