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
120
fava_client.py
120
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
|
||||
"""
|
||||
# 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:
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue