Fix BQL balance queries mixing EUR and SATS face values

The BQL queries in get_user_balance_bql() and get_all_user_balances_bql()
used GROUP BY account without currency, causing sum(number) to add EUR
face values from expense entries (EUR @@ SATS notation) with SATS face
values from payment entries (plain SATS). This inflated displayed fiat
amounts by orders of magnitude for users with settlement payments.

Fix: add currency to GROUP BY so EUR and SATS rows are separate, use
sum(weight) for net SATS (correct across all entry formats), and scale
fiat proportionally for partial settlements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-31 11:43:38 -04:00
parent c4f784360d
commit b06c53c40f

View file

@ -734,17 +734,23 @@ class FavaClient:
async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]: async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
""" """
Get user balance using BQL with price notation (efficient server-side aggregation). Get user balance using BQL with currency-grouped aggregation.
Uses sum(weight) to aggregate SATS from @@ price notation. Groups by account AND currency to correctly handle mixed entry formats:
This provides 5-10x performance improvement over manual aggregation. - Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
- Payment entries use plain SATS (position=SATS, weight=SATS)
Without currency grouping, sum(number) would mix EUR and SATS face values.
The net SATS balance is computed from sum(weight) which normalizes to SATS
across both formats. Fiat is taken only from EUR rows and scaled by the
fraction of SATS debt still outstanding.
Args: Args:
user_id: User ID user_id: User ID
Returns: Returns:
{ {
"balance": int (sats from weight column), "balance": int (net sats owed),
"fiat_balances": {"EUR": Decimal("100.50"), ...}, "fiat_balances": {"EUR": Decimal("100.50"), ...},
"accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...] "accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...]
} }
@ -757,49 +763,62 @@ class FavaClient:
user_id_prefix = user_id[:8] user_id_prefix = user_id[:8]
# BQL query using sum(weight) for SATS aggregation # GROUP BY currency prevents mixing EUR and SATS face values in sum(number).
# weight column returns the @@ price value (SATS) from price notation # sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries.
# sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid.
query = f""" query = f"""
SELECT account, sum(number), sum(weight) SELECT account, currency, sum(number), sum(weight)
WHERE account ~ ':User-{user_id_prefix}' WHERE account ~ ':User-{user_id_prefix}'
AND (account ~ 'Payable' OR account ~ 'Receivable') AND (account ~ 'Payable' OR account ~ 'Receivable')
AND flag = '*' AND flag = '*'
GROUP BY account GROUP BY account, currency
""" """
result = await self.query_bql(query) result = await self.query_bql(query)
total_sats = 0 # First pass: collect EUR fiat totals and SATS weights per account
total_eur_sats = 0 # SATS equivalent of EUR entries (from weight)
total_sats_paid = 0 # SATS from payment entries
fiat_balances = {} fiat_balances = {}
accounts = [] accounts = []
for row in result["rows"]: for row in result["rows"]:
account_name, fiat_sum, weight_sum = row account_name, currency, number_sum, weight_sum = row
# Parse fiat amount (sum of EUR/USD amounts) # Parse SATS from weight column (always SATS for both entry formats)
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) sats_weight = 0
# Parse SATS from weight column
# weight_sum is an Inventory dict like {"SATS": -10442635.00}
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum: if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_value = weight_sum["SATS"] sats_weight = int(Decimal(str(weight_sum["SATS"])))
sats_amount = int(Decimal(str(sats_value)))
total_sats += sats_amount if currency == "SATS":
# Payment entry: SATS position, track separately
total_sats_paid += int(Decimal(str(number_sum))) if number_sum else 0
else:
# EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
total_eur_sats += sats_weight
# Aggregate fiat (assume EUR for now, could be extended)
if fiat_amount != 0: if fiat_amount != 0:
if "EUR" not in fiat_balances: if currency not in fiat_balances:
fiat_balances["EUR"] = Decimal(0) fiat_balances[currency] = Decimal(0)
fiat_balances["EUR"] += fiat_amount fiat_balances[currency] += fiat_amount
accounts.append({ accounts.append({
"account": account_name, "account": account_name,
"sats": sats_amount, "sats": sats_weight,
"eur": fiat_amount "eur": fiat_amount
}) })
# Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
total_sats = total_eur_sats + total_sats_paid
# Scale fiat proportionally if partially settled
# e.g., if 80% of SATS debt paid, reduce fiat owed by 80%
if total_eur_sats != 0 and total_sats_paid != 0:
remaining_fraction = Decimal(str(total_sats)) / Decimal(str(total_eur_sats))
for currency in fiat_balances:
fiat_balances[currency] = (fiat_balances[currency] * remaining_fraction).quantize(Decimal("0.01"))
logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}") logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}")
return { return {
@ -810,10 +829,14 @@ class FavaClient:
async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]: async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
""" """
Get balances for all users using BQL with price notation (efficient admin view). Get balances for all users using BQL with currency-grouped aggregation.
Uses sum(weight) to aggregate SATS from @@ price notation in a single query. Groups by account AND currency to correctly handle mixed entry formats:
This provides significant performance benefits for admin views. - Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
- Payment entries use plain SATS (position=SATS, weight=SATS)
Without currency grouping, sum(number) would mix EUR and SATS face values,
causing wildly inflated fiat amounts for users with payment entries.
Returns: Returns:
[ [
@ -833,23 +856,22 @@ class FavaClient:
""" """
from decimal import Decimal from decimal import Decimal
# BQL query using sum(weight) for SATS aggregation # GROUP BY currency prevents mixing EUR and SATS face values in sum(number)
query = """ query = """
SELECT account, sum(number), sum(weight) SELECT account, currency, sum(number), sum(weight)
WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
AND flag = '*' AND flag = '*'
GROUP BY account GROUP BY account, currency
""" """
result = await self.query_bql(query) result = await self.query_bql(query)
# Group by user_id # First pass: collect per-user EUR fiat totals and SATS amounts separately
user_data = {} user_data = {}
for row in result["rows"]: for row in result["rows"]:
account_name, fiat_sum, weight_sum = row account_name, currency, number_sum, weight_sum = row
# Extract user_id from account name
if ":User-" not in account_name: if ":User-" not in account_name:
continue continue
@ -861,32 +883,49 @@ class FavaClient:
"user_id": user_id, "user_id": user_id,
"balance": 0, "balance": 0,
"fiat_balances": {}, "fiat_balances": {},
"accounts": [] "accounts": [],
"_eur_sats": 0, # SATS equivalent of EUR entries (from weight)
"_sats_paid": 0, # SATS from payment entries
} }
# Parse fiat amount # Parse SATS from weight column (always SATS for both entry formats)
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) sats_weight = 0
# Parse SATS from weight column
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum: if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_value = weight_sum["SATS"] sats_weight = int(Decimal(str(weight_sum["SATS"])))
sats_amount = int(Decimal(str(sats_value)))
user_data[user_id]["balance"] += sats_amount if currency == "SATS":
# Payment entry: SATS position, track separately
user_data[user_id]["_sats_paid"] += int(Decimal(str(number_sum))) if number_sum else 0
else:
# EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
user_data[user_id]["_eur_sats"] += sats_weight
# Aggregate fiat
if fiat_amount != 0: if fiat_amount != 0:
if "EUR" not in user_data[user_id]["fiat_balances"]: if currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0) user_data[user_id]["fiat_balances"][currency] = Decimal(0)
user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount user_data[user_id]["fiat_balances"][currency] += fiat_amount
user_data[user_id]["accounts"].append({ user_data[user_id]["accounts"].append({
"account": account_name, "account": account_name,
"sats": sats_amount, "sats": sats_weight,
"eur": fiat_amount "eur": fiat_amount
}) })
# Second pass: compute net balances and scale fiat for partial settlements
for user_id, data in user_data.items():
eur_sats = data.pop("_eur_sats")
sats_paid = data.pop("_sats_paid")
# Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
data["balance"] = eur_sats + sats_paid
# Scale fiat proportionally if partially settled
if eur_sats != 0 and sats_paid != 0:
remaining_fraction = Decimal(str(data["balance"])) / Decimal(str(eur_sats))
for currency in data["fiat_balances"]:
data["fiat_balances"][currency] = (data["fiat_balances"][currency] * remaining_fraction).quantize(Decimal("0.01"))
logger.info(f"Fetched balances for {len(user_data)} users (BQL)") logger.info(f"Fetched balances for {len(user_data)} users (BQL)")
return list(user_data.values()) return list(user_data.values())