Add lifetime income/expense totals to UserBalance

New get_user_lifetime_totals_bql() runs tag-filtered BQL queries
(Payable + expense-entry, Receivable + income-entry) to compute
per-user lifetime totals separately from the net balance. Plumbed
through /api/v1/balance and /api/v1/balance/{user_id}; existing
clients keep working (fields default to zero / empty dict).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-17 16:06:16 +02:00
commit deeec7e2c5
3 changed files with 82 additions and 0 deletions

View file

@ -827,6 +827,66 @@ class FavaClient:
"accounts": accounts
}
async def get_user_lifetime_totals_bql(self, user_id: str) -> Dict[str, Any]:
"""
Get lifetime totals of expenses submitted and income recorded by this user.
Sums original entries only (tag-filtered) does not net against payments
or other reconciliation activity, so totals match "amounts ever entered".
Args:
user_id: User ID
Returns:
{
"total_expenses_sats": int,
"total_expenses_fiat": {"EUR": Decimal("...")},
"total_income_sats": int,
"total_income_fiat": {"EUR": Decimal("...")},
}
"""
from decimal import Decimal
user_id_prefix = user_id[:8]
async def _sum_for(account_pattern: str, tag: str):
query = f"""
SELECT currency, sum(number), sum(weight)
WHERE account ~ '{account_pattern}:User-{user_id_prefix}'
AND '{tag}' IN tags
AND flag = '*'
GROUP BY currency
"""
result = await self.query_bql(query)
sats_total = 0
fiat_total: Dict[str, Decimal] = {}
for row in result["rows"]:
currency, number_sum, weight_sum = row
# Skip SATS-currency rows (payment/reconciliation legs)
if currency == "SATS":
continue
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_total += abs(int(Decimal(str(weight_sum["SATS"]))))
fiat_amount = abs(Decimal(str(number_sum))) if number_sum else Decimal(0)
if fiat_amount > 0:
fiat_total[currency] = fiat_total.get(currency, Decimal(0)) + fiat_amount
return sats_total, fiat_total
exp_sats, exp_fiat = await _sum_for("Liabilities:Payable", "expense-entry")
inc_sats, inc_fiat = await _sum_for("Assets:Receivable", "income-entry")
logger.info(
f"User {user_id[:8]} lifetime totals (BQL): "
f"expenses={exp_sats} sats {dict(exp_fiat)}, income={inc_sats} sats {dict(inc_fiat)}"
)
return {
"total_expenses_sats": exp_sats,
"total_expenses_fiat": exp_fiat,
"total_income_sats": inc_sats,
"total_income_fiat": inc_fiat,
}
async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
"""
Get balances for all users using BQL with currency-grouped aggregation.