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 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]]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
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)
|
# 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"
|
||||||
|
|
|
||||||
10
views_api.py
10
views_api.py
|
|
@ -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')}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue