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.
This commit is contained in:
padreug 2025-11-10 02:18:49 +01:00
parent 5c1c7b1b05
commit 8396331d5a
3 changed files with 71 additions and 74 deletions

View file

@ -17,9 +17,7 @@ import httpx
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from decimal import Decimal from decimal import Decimal
from datetime import date, datetime from datetime import date, datetime
import logging from loguru import logger
logger = logging.getLogger(__name__)
class FavaClient: class FavaClient:
@ -192,89 +190,74 @@ class FavaClient:
Excludes pending transactions (flag='!') from balance calculation. Excludes pending transactions (flag='!') from balance calculation.
Only cleared/completed transactions (flag='*') are included. Only cleared/completed transactions (flag='*') are included.
""" """
# Query for all accounts matching user (excluding pending) # Get all journal entries for this user
query = f""" all_entries = await self.get_journal_entries()
SELECT account, sum(position)
WHERE account ~ 'User-{user_id[:8]}' AND flag != '!'
GROUP BY account
"""
try: total_sats = 0
async with httpx.AsyncClient(timeout=self.timeout) as client: fiat_balances = {}
response = await client.get( accounts_dict = {} # Track balances per account
f"{self.base_url}/query",
params={"query_string": query}
)
response.raise_for_status()
data = response.json()
# Calculate user balance for entry in all_entries:
total_sats = 0 # Skip non-transactions, pending (!), and voided
fiat_balances = {} if entry.get("t") != "Transaction":
accounts = [] continue
if entry.get("flag") == "!":
continue
if "voided" in entry.get("tags", []):
continue
for row in data['data']['rows']: # Process postings for this user
account_name = row[0] for posting in entry.get("postings", []):
positions = row[1] # {"SATS": {cost: amount, ...}} 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 # Parse amount string: "36791 SATS {33.33 EUR}"
if isinstance(positions, dict) and "SATS" in positions: amount_str = posting.get("amount", "")
sats_positions = positions["SATS"] if not isinstance(amount_str, str) or not amount_str:
continue
if isinstance(sats_positions, dict): import re
# Positions with cost basis: {"100.00 EUR": 200000, ...} # Extract SATS amount (with sign)
for cost_str, amount in sats_positions.items(): sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
amount_int = int(amount) if sats_match:
sats_amount = int(sats_match.group(1))
total_sats += sats_amount
# Use Beancount balance as-is (castle's perspective) # Track per account
# - Receivable (Asset): positive = user owes castle if account_name not in accounts_dict:
# - Payable (Liability): negative = castle owes user accounts_dict[account_name] = {"account": account_name, "sats": 0}
total_sats += amount_int accounts_dict[account_name]["sats"] += sats_amount
account_balance["sats"] += amount_int
# Extract fiat amount from cost basis # Extract fiat from cost syntax: {33.33 EUR}
# Format: "100.00 EUR" or "{100.00 EUR}" cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
if cost_str and cost_str != "SATS": if cost_match:
cost_clean = cost_str.strip('{}') fiat_amount_unsigned = Decimal(cost_match.group(1))
parts = cost_clean.split() fiat_currency = cost_match.group(2)
if len(parts) == 2:
try:
fiat_amount = Decimal(parts[0])
fiat_currency = parts[1]
if fiat_currency not in fiat_balances: if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0) fiat_balances[fiat_currency] = Decimal(0)
# Apply same sign as sats amount # Apply the same sign as the SATS amount
if amount_int < 0: if sats_match:
fiat_amount = -fiat_amount sats_amount_for_sign = int(sats_match.group(1))
fiat_balances[fiat_currency] += fiat_amount if sats_amount_for_sign < 0:
except (ValueError, IndexError): fiat_amount_unsigned = -fiat_amount_unsigned
logger.warning(f"Could not parse cost basis: {cost_str}")
elif isinstance(sats_positions, (int, float)): fiat_balances[fiat_currency] += fiat_amount_unsigned
# Simple number (no cost basis) logger.info(f"Found fiat in {account_name}: {fiat_amount_unsigned} {fiat_currency}, running total: {fiat_balances[fiat_currency]}")
amount_int = int(sats_positions)
# Use Beancount balance as-is
total_sats += amount_int
account_balance["sats"] += amount_int
accounts.append(account_balance) result = {
"balance": total_sats,
return { "fiat_balances": fiat_balances,
"balance": total_sats, "accounts": list(accounts_dict.values())
"fiat_balances": fiat_balances, }
"accounts": accounts logger.info(f"Returning balance for user {user_id[:8]}: sats={total_sats}, fiat_balances={fiat_balances}")
} return result
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 get_all_user_balances(self) -> List[Dict[str, Any]]: async def get_all_user_balances(self) -> List[Dict[str, Any]]:
""" """

View file

@ -183,12 +183,16 @@ async def on_invoice_paid(payment: Payment) -> None:
# Extract fiat metadata from invoice (if present) # Extract fiat metadata from invoice (if present)
fiat_currency = None fiat_currency = None
fiat_amount = None fiat_amount = None
logger.info(f"Payment.extra in webhook: {payment.extra}")
if payment.extra: if payment.extra:
fiat_currency = payment.extra.get("fiat_currency") fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount") 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: if fiat_amount_str:
fiat_amount = Decimal(str(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) # Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account( user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable" user_id, AccountType.ASSET, "Accounts Receivable"

View file

@ -3,6 +3,7 @@ from decimal import Decimal
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models import User, WalletTypeInfo from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import ( from lnbits.decorators import (
check_super_user, check_super_user,
@ -1234,6 +1235,8 @@ async def api_generate_payment_invoice(
# Calculate proportional fiat amount for this invoice # Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "castle", "user_id": target_user_id} 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: if user_balance.fiat_balances:
# Simple single-currency solution: use the first (and should be only) currency # Simple single-currency solution: use the first (and should be only) currency
currencies = list(user_balance.fiat_balances.keys()) currencies = list(user_balance.fiat_balances.keys())
@ -1267,6 +1270,8 @@ async def api_generate_payment_invoice(
"btc_rate": btc_rate, "btc_rate": btc_rate,
}) })
logger.info(f"Invoice extra metadata: {invoice_extra}")
# Create invoice on castle wallet # Create invoice on castle wallet
invoice_data = CreateInvoice( invoice_data = CreateInvoice(
out=False, out=False,
@ -1366,12 +1371,15 @@ async def api_record_payment(
fiat_currency = None fiat_currency = None
fiat_amount = None fiat_amount = None
if payment.extra and isinstance(payment.extra, dict): if payment.extra and isinstance(payment.extra, dict):
logger.info(f"Payment.extra contents: {payment.extra}")
fiat_currency = payment.extra.get("fiat_currency") fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount") fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str: if fiat_amount_str:
from decimal import Decimal from decimal import Decimal
fiat_amount = Decimal(str(fiat_amount_str)) 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) # Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account( user_receivable = await get_or_create_user_account(
target_user_id, AccountType.ASSET, "Accounts Receivable" target_user_id, AccountType.ASSET, "Accounts Receivable"
@ -1399,6 +1407,8 @@ async def api_record_payment(
reference=data.payment_hash reference=data.payment_hash
) )
logger.info(f"Formatted payment entry: {entry}")
# Submit to Fava # Submit to Fava
result = await fava.add_entry(entry) result = await fava.add_entry(entry)
logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}") logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")