From 8396331d5a6a8be0ccd80dbf86de9182682db2d3 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 10 Nov 2025 02:18:49 +0100 Subject: [PATCH] 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. --- fava_client.py | 131 +++++++++++++++++++++---------------------------- tasks.py | 4 ++ views_api.py | 10 ++++ 3 files changed, 71 insertions(+), 74 deletions(-) diff --git a/fava_client.py b/fava_client.py index e87ddeb..5619604 100644 --- a/fava_client.py +++ b/fava_client.py @@ -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]]: """ diff --git a/tasks.py b/tasks.py index 0bec668..4c84065 100644 --- a/tasks.py +++ b/tasks.py @@ -183,12 +183,16 @@ async def on_invoice_paid(payment: Payment) -> None: # Extract fiat metadata from invoice (if present) fiat_currency = None fiat_amount = None + logger.info(f"Payment.extra in webhook: {payment.extra}") if payment.extra: fiat_currency = payment.extra.get("fiat_currency") fiat_amount_str = payment.extra.get("fiat_amount") + logger.info(f"Extracted from extra - fiat_currency: {fiat_currency}, fiat_amount_str: {fiat_amount_str}") if fiat_amount_str: fiat_amount = Decimal(str(fiat_amount_str)) + logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}") + # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( user_id, AccountType.ASSET, "Accounts Receivable" diff --git a/views_api.py b/views_api.py index 4ce31c3..6505dee 100644 --- a/views_api.py +++ b/views_api.py @@ -3,6 +3,7 @@ from decimal import Decimal from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException +from loguru import logger from lnbits.core.models import User, WalletTypeInfo from lnbits.decorators import ( check_super_user, @@ -1234,6 +1235,8 @@ async def api_generate_payment_invoice( # Calculate proportional fiat amount for this invoice invoice_extra = {"tag": "castle", "user_id": target_user_id} + logger.info(f"User balance for invoice generation - sats: {user_balance.balance}, fiat_balances: {user_balance.fiat_balances}") + if user_balance.fiat_balances: # Simple single-currency solution: use the first (and should be only) currency currencies = list(user_balance.fiat_balances.keys()) @@ -1267,6 +1270,8 @@ async def api_generate_payment_invoice( "btc_rate": btc_rate, }) + logger.info(f"Invoice extra metadata: {invoice_extra}") + # Create invoice on castle wallet invoice_data = CreateInvoice( out=False, @@ -1366,12 +1371,15 @@ async def api_record_payment( fiat_currency = None fiat_amount = None if payment.extra and isinstance(payment.extra, dict): + logger.info(f"Payment.extra contents: {payment.extra}") fiat_currency = payment.extra.get("fiat_currency") fiat_amount_str = payment.extra.get("fiat_amount") if fiat_amount_str: from decimal import Decimal fiat_amount = Decimal(str(fiat_amount_str)) + logger.info(f"Extracted fiat metadata - currency: {fiat_currency}, amount: {fiat_amount}") + # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( target_user_id, AccountType.ASSET, "Accounts Receivable" @@ -1399,6 +1407,8 @@ async def api_record_payment( reference=data.payment_hash ) + logger.info(f"Formatted payment entry: {entry}") + # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")