Calculates user balances from journal entries

Refactors user balance calculation to use journal entries
instead of querying Fava's query endpoint.

This change allows for exclusion of voided transactions
(tagged with #voided) in addition to pending transactions
when calculating user balances, providing more accurate
balance information.

Additionally the change improves parsing of the amounts in journal entries by using regular expressions.
This commit is contained in:
padreug 2025-11-10 01:16:04 +01:00
parent 3cb3b23a8d
commit e154a8b427

View file

@ -296,37 +296,36 @@ class FavaClient:
] ]
Note: Note:
Excludes pending transactions (flag='!') from balance calculation. Excludes pending transactions (flag='!') and voided (tag #voided) from balance calculation.
Only cleared/completed transactions (flag='*') are included. Only cleared/completed transactions (flag='*') are included.
""" """
query = """ # Get all journal entries and calculate balances from postings
SELECT account, sum(position) all_entries = await self.get_journal_entries()
WHERE account ~ 'Payable:User-|Receivable:User-' AND flag != '!'
GROUP BY account
"""
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()
# Group by user_id # Group by user_id
user_data = {} user_data = {}
for row in data['data']['rows']: for entry in all_entries:
account_name = row[0] # Skip non-transactions, pending (!), and voided
positions = row[1] if entry.get("t") != "Transaction":
continue
if entry.get("flag") == "!":
continue
if "voided" in entry.get("tags", []):
continue
# Process postings
for posting in entry.get("postings", []):
account_name = posting.get("account", "")
# Only process user accounts (Payable or Receivable)
if ":User-" not in account_name:
continue
if "Payable" not in account_name and "Receivable" not in account_name:
continue
# Extract user_id from account name # Extract user_id from account name
# e.g., "Liabilities:Payable:User-abc123" → "abc123..."
if ":User-" in account_name:
user_id = account_name.split(":User-")[1] user_id = account_name.split(":User-")[1]
else:
continue
if user_id not in user_data: if user_id not in user_data:
user_data[user_id] = { user_data[user_id] = {
@ -336,59 +335,40 @@ class FavaClient:
"accounts": [] "accounts": []
} }
account_info = {"account": account_name, "sats": 0, "positions": positions} # Parse amount string: "36791 SATS {33.33 EUR, 2025-11-09}"
amount_str = posting.get("amount", "")
if not isinstance(amount_str, str) or not amount_str:
continue
# Process positions import re
if isinstance(positions, dict) and "SATS" in positions: # Extract SATS amount
sats_positions = positions["SATS"] sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
if isinstance(sats_positions, dict): sats_amount = int(sats_match.group(1))
for cost_str, amount in sats_positions.items():
amount_int = int(amount)
# Negate Beancount balance for user perspective # Negate Beancount balance for user perspective
adjusted_amount = -amount_int # Payable (liability): negative in Beancount = castle owes user (positive for user)
# Receivable (asset): positive in Beancount = user owes castle (negative for user)
adjusted_amount = -sats_amount
user_data[user_id]["balance"] += adjusted_amount user_data[user_id]["balance"] += adjusted_amount
account_info["sats"] += adjusted_amount
# Extract fiat # Extract fiat from cost syntax: {33.33 EUR, ...}
if cost_str and cost_str != "SATS": cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
cost_clean = cost_str.strip('{}') if cost_match:
parts = cost_clean.split() fiat_amount_raw = Decimal(cost_match.group(1))
if len(parts) == 2: fiat_currency = cost_match.group(2)
try:
fiat_amount = Decimal(parts[0])
fiat_currency = parts[1]
if fiat_currency not in user_data[user_id]["fiat_balances"]: if fiat_currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0) user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
# Apply sign from amount to fiat # Apply same sign logic as sats
if amount_int < 0: if "-" in amount_str:
fiat_amount = -fiat_amount fiat_amount_raw = -fiat_amount_raw
adjusted_fiat = -fiat_amount adjusted_fiat = -fiat_amount_raw
user_data[user_id]["fiat_balances"][fiat_currency] += adjusted_fiat user_data[user_id]["fiat_balances"][fiat_currency] += adjusted_fiat
except (ValueError, IndexError):
pass
elif isinstance(sats_positions, (int, float)):
amount_int = int(sats_positions)
# Negate Beancount balance for user perspective
adjusted_amount = -amount_int
user_data[user_id]["balance"] += adjusted_amount
account_info["sats"] += adjusted_amount
user_data[user_id]["accounts"].append(account_info)
return list(user_data.values()) return list(user_data.values())
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
async def check_fava_health(self) -> bool: async def check_fava_health(self) -> bool:
""" """
Check if Fava is running and accessible. Check if Fava is running and accessible.