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 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]]:
"""

View file

@ -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"

View file

@ -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')}")