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 "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]]: async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
""" """
Get balances for all users using BQL with currency-grouped aggregation. Get balances for all users using BQL with currency-grouped aggregation.

View file

@ -90,6 +90,11 @@ class UserBalance(BaseModel):
balance: int # positive = libra owes user, negative = user owes libra balance: int # positive = libra owes user, negative = user owes libra
accounts: list[Account] = [] accounts: list[Account] = []
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")} fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
# Lifetime totals (original entries only; not net of reconciliation)
total_expenses_sats: int = 0
total_expenses_fiat: dict[str, Decimal] = {}
total_income_sats: int = 0
total_income_fiat: dict[str, Decimal] = {}
class ExpenseEntry(BaseModel): class ExpenseEntry(BaseModel):

View file

@ -1591,22 +1591,34 @@ async def api_get_my_balance(
# Add all balances (positive and negative) # Add all balances (positive and negative)
total_fiat_balances[currency] += amount total_fiat_balances[currency] += amount
# Super-user totals reflect their personal submissions (if any), not org-wide
super_totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
# Return net position # Return net position
return UserBalance( return UserBalance(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
balance=net_balance, balance=net_balance,
accounts=[], accounts=[],
fiat_balances=total_fiat_balances, fiat_balances=total_fiat_balances,
total_expenses_sats=super_totals["total_expenses_sats"],
total_expenses_fiat=super_totals["total_expenses_fiat"],
total_income_sats=super_totals["total_income_sats"],
total_income_fiat=super_totals["total_income_fiat"],
) )
# For regular users, show their individual balance from Fava # For regular users, show their individual balance from Fava
balance_data = await fava.get_user_balance_bql(wallet.wallet.user) balance_data = await fava.get_user_balance_bql(wallet.wallet.user)
totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
return UserBalance( return UserBalance(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
balance=balance_data["balance"], balance=balance_data["balance"],
accounts=[], # Could populate from balance_data["accounts"] if needed accounts=[], # Could populate from balance_data["accounts"] if needed
fiat_balances=balance_data["fiat_balances"], fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
) )
@ -1627,12 +1639,17 @@ async def api_get_user_balance(
fava = get_fava_client() fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(user_id) balance_data = await fava.get_user_balance_bql(user_id)
totals = await fava.get_user_lifetime_totals_bql(user_id)
return UserBalance( return UserBalance(
user_id=user_id, user_id=user_id,
balance=balance_data["balance"], balance=balance_data["balance"],
accounts=[], accounts=[],
fiat_balances=balance_data["fiat_balances"], fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
) )