Calculates user balance from journal entries

Refactors user balance calculation to directly parse journal
entries, enhancing accuracy and efficiency. This change
eliminates reliance on direct database queries and provides a
more reliable mechanism for determining user balances.

Adds logging for debugging purposes.

Also extracts and uses fiat metadata from invoice/payment extras.
This commit is contained in:
padreug 2025-11-10 02:18:49 +01:00
parent 5c1c7b1b05
commit 8396331d5a
3 changed files with 71 additions and 74 deletions

View file

@ -17,9 +17,7 @@ import httpx
from typing import Any, Dict, List, Optional
from decimal import Decimal
from datetime import date, datetime
import logging
logger = logging.getLogger(__name__)
from loguru import logger
class FavaClient:
@ -192,89 +190,74 @@ class FavaClient:
Excludes pending transactions (flag='!') from balance calculation.
Only cleared/completed transactions (flag='*') are included.
"""
# Query for all accounts matching user (excluding pending)
query = f"""
SELECT account, sum(position)
WHERE account ~ 'User-{user_id[:8]}' AND flag != '!'
GROUP BY account
"""
# Get all journal entries for this user
all_entries = await self.get_journal_entries()
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/query",
params={"query_string": query}
)
response.raise_for_status()
data = response.json()
total_sats = 0
fiat_balances = {}
accounts_dict = {} # Track balances per account
# Calculate user balance
total_sats = 0
fiat_balances = {}
accounts = []
for entry in all_entries:
# Skip non-transactions, pending (!), and voided
if entry.get("t") != "Transaction":
continue
if entry.get("flag") == "!":
continue
if "voided" in entry.get("tags", []):
continue
for row in data['data']['rows']:
account_name = row[0]
positions = row[1] # {"SATS": {cost: amount, ...}}
# Process postings for this user
for posting in entry.get("postings", []):
account_name = posting.get("account", "")
account_balance = {"account": account_name, "sats": 0, "positions": positions}
# Only process this user's accounts (account names use first 8 chars of user_id)
if f":User-{user_id[:8]}" not in account_name:
continue
if "Payable" not in account_name and "Receivable" not in account_name:
continue
# Process SATS positions
if isinstance(positions, dict) and "SATS" in positions:
sats_positions = positions["SATS"]
# Parse amount string: "36791 SATS {33.33 EUR}"
amount_str = posting.get("amount", "")
if not isinstance(amount_str, str) or not amount_str:
continue
if isinstance(sats_positions, dict):
# Positions with cost basis: {"100.00 EUR": 200000, ...}
for cost_str, amount in sats_positions.items():
amount_int = int(amount)
import re
# Extract SATS amount (with sign)
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = int(sats_match.group(1))
total_sats += sats_amount
# Use Beancount balance as-is (castle's perspective)
# - Receivable (Asset): positive = user owes castle
# - Payable (Liability): negative = castle owes user
total_sats += amount_int
account_balance["sats"] += amount_int
# Track per account
if account_name not in accounts_dict:
accounts_dict[account_name] = {"account": account_name, "sats": 0}
accounts_dict[account_name]["sats"] += sats_amount
# Extract fiat amount from cost basis
# Format: "100.00 EUR" or "{100.00 EUR}"
if cost_str and cost_str != "SATS":
cost_clean = cost_str.strip('{}')
parts = cost_clean.split()
if len(parts) == 2:
try:
fiat_amount = Decimal(parts[0])
fiat_currency = parts[1]
# Extract fiat from cost syntax: {33.33 EUR}
cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
if cost_match:
fiat_amount_unsigned = Decimal(cost_match.group(1))
fiat_currency = cost_match.group(2)
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0)
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0)
# Apply same sign as sats amount
if amount_int < 0:
fiat_amount = -fiat_amount
fiat_balances[fiat_currency] += fiat_amount
except (ValueError, IndexError):
logger.warning(f"Could not parse cost basis: {cost_str}")
# Apply the same sign as the SATS amount
if sats_match:
sats_amount_for_sign = int(sats_match.group(1))
if sats_amount_for_sign < 0:
fiat_amount_unsigned = -fiat_amount_unsigned
elif isinstance(sats_positions, (int, float)):
# Simple number (no cost basis)
amount_int = int(sats_positions)
# Use Beancount balance as-is
total_sats += amount_int
account_balance["sats"] += amount_int
fiat_balances[fiat_currency] += fiat_amount_unsigned
logger.info(f"Found fiat in {account_name}: {fiat_amount_unsigned} {fiat_currency}, running total: {fiat_balances[fiat_currency]}")
accounts.append(account_balance)
return {
"balance": total_sats,
"fiat_balances": fiat_balances,
"accounts": accounts
}
except httpx.HTTPStatusError as e:
logger.error(f"Fava query error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
result = {
"balance": total_sats,
"fiat_balances": fiat_balances,
"accounts": list(accounts_dict.values())
}
logger.info(f"Returning balance for user {user_id[:8]}: sats={total_sats}, fiat_balances={fiat_balances}")
return result
async def get_all_user_balances(self) -> List[Dict[str, Any]]:
"""