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:
parent
5c1c7b1b05
commit
8396331d5a
3 changed files with 71 additions and 74 deletions
131
fava_client.py
131
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]]:
|
||||
"""
|
||||
|
|
|
|||
4
tasks.py
4
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"
|
||||
|
|
|
|||
10
views_api.py
10
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')}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue