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:
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.
"""
query = """
SELECT account, sum(position)
WHERE account ~ 'Payable:User-|Receivable:User-' AND flag != '!'
GROUP BY account
"""
# Get all journal entries and calculate balances from postings
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()
# Group by user_id
user_data = {}
# Group by user_id
user_data = {}
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]
# 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
# e.g., "Liabilities:Payable:User-abc123" → "abc123..."
if ":User-" in account_name:
user_id = account_name.split(":User-")[1]
else:
continue
user_id = account_name.split(":User-")[1]
if user_id not in user_data:
user_data[user_id] = {
@ -336,58 +335,39 @@ class FavaClient:
"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
if isinstance(positions, dict) and "SATS" in positions:
sats_positions = positions["SATS"]
import re
# Extract SATS amount
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = int(sats_match.group(1))
if isinstance(sats_positions, dict):
for cost_str, amount in sats_positions.items():
amount_int = int(amount)
# Negate Beancount balance for user perspective
# 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
# Negate Beancount balance for user perspective
adjusted_amount = -amount_int
user_data[user_id]["balance"] += adjusted_amount
account_info["sats"] += adjusted_amount
# Extract fiat from cost syntax: {33.33 EUR, ...}
cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
if cost_match:
fiat_amount_raw = Decimal(cost_match.group(1))
fiat_currency = cost_match.group(2)
# Extract fiat
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]
if fiat_currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
if fiat_currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
# Apply same sign logic as sats
if "-" in amount_str:
fiat_amount_raw = -fiat_amount_raw
adjusted_fiat = -fiat_amount_raw
user_data[user_id]["fiat_balances"][fiat_currency] += adjusted_fiat
# Apply sign from amount to fiat
if amount_int < 0:
fiat_amount = -fiat_amount
adjusted_fiat = -fiat_amount
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())
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
return list(user_data.values())
async def check_fava_health(self) -> bool:
"""