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:
parent
3cb3b23a8d
commit
e154a8b427
1 changed files with 50 additions and 70 deletions
104
fava_client.py
104
fava_client.py
|
|
@ -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
|
||||
"""
|
||||
|
||||
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()
|
||||
# Get all journal entries and calculate balances from postings
|
||||
all_entries = await self.get_journal_entries()
|
||||
|
||||
# Group by user_id
|
||||
user_data = {}
|
||||
|
||||
for row in data['data']['rows']:
|
||||
account_name = row[0]
|
||||
positions = row[1]
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
if user_id not in user_data:
|
||||
user_data[user_id] = {
|
||||
|
|
@ -336,59 +335,40 @@ 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"]
|
||||
|
||||
if isinstance(sats_positions, dict):
|
||||
for cost_str, amount in sats_positions.items():
|
||||
amount_int = int(amount)
|
||||
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))
|
||||
|
||||
# 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
|
||||
account_info["sats"] += adjusted_amount
|
||||
|
||||
# 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]
|
||||
# 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)
|
||||
|
||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
|
||||
|
||||
# Apply sign from amount to fiat
|
||||
if amount_int < 0:
|
||||
fiat_amount = -fiat_amount
|
||||
adjusted_fiat = -fiat_amount
|
||||
# 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
|
||||
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
|
||||
|
||||
async def check_fava_health(self) -> bool:
|
||||
"""
|
||||
Check if Fava is running and accessible.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue