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:
padreug 2025-12-15 01:15:29 +01:00
parent addf4cd05f
commit 7dabe8700d
2 changed files with 291 additions and 0 deletions

View file

@ -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,