From b06c53c40f02c7eca655758cb75b002e68b4a8b9 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Tue, 31 Mar 2026 11:43:38 -0400 Subject: [PATCH] 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) --- fava_client.py | 153 +++++++++++++++++++++++++++++++------------------ 1 file changed, 96 insertions(+), 57 deletions(-) diff --git a/fava_client.py b/fava_client.py index 6880d11..fea8c64 100644 --- a/fava_client.py +++ b/fava_client.py @@ -734,17 +734,23 @@ class FavaClient: 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. - This provides 5-10x performance improvement over manual aggregation. + Groups by account AND currency to correctly handle mixed entry formats: + - 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: user_id: User ID Returns: { - "balance": int (sats from weight column), + "balance": int (net sats owed), "fiat_balances": {"EUR": Decimal("100.50"), ...}, "accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...] } @@ -757,48 +763,61 @@ class FavaClient: user_id_prefix = user_id[:8] - # BQL query using sum(weight) for SATS aggregation - # weight column returns the @@ price value (SATS) from price notation + # GROUP BY currency prevents mixing EUR and SATS face values in sum(number). + # 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""" - SELECT account, sum(number), sum(weight) + SELECT account, currency, sum(number), sum(weight) WHERE account ~ ':User-{user_id_prefix}' AND (account ~ 'Payable' OR account ~ 'Receivable') AND flag = '*' - GROUP BY account + GROUP BY account, currency """ 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 = {} accounts = [] 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) - fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) - - # Parse SATS from weight column - # weight_sum is an Inventory dict like {"SATS": -10442635.00} - sats_amount = 0 + # Parse SATS from weight column (always SATS for both entry formats) + sats_weight = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_value = weight_sum["SATS"] - sats_amount = int(Decimal(str(sats_value))) + sats_weight = int(Decimal(str(weight_sum["SATS"]))) - 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 "EUR" not in fiat_balances: - fiat_balances["EUR"] = Decimal(0) - fiat_balances["EUR"] += fiat_amount + if fiat_amount != 0: + if currency not in fiat_balances: + fiat_balances[currency] = Decimal(0) + fiat_balances[currency] += fiat_amount - accounts.append({ - "account": account_name, - "sats": sats_amount, - "eur": fiat_amount - }) + accounts.append({ + "account": account_name, + "sats": sats_weight, + "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)}") @@ -810,10 +829,14 @@ class FavaClient: 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. - This provides significant performance benefits for admin views. + Groups by account AND currency to correctly handle mixed entry formats: + - 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: [ @@ -833,23 +856,22 @@ class FavaClient: """ 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 = """ - SELECT account, sum(number), sum(weight) + SELECT account, currency, sum(number), sum(weight) WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') AND flag = '*' - GROUP BY account + GROUP BY account, currency """ result = await self.query_bql(query) - # Group by user_id + # First pass: collect per-user EUR fiat totals and SATS amounts separately user_data = {} 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: continue @@ -861,31 +883,48 @@ class FavaClient: "user_id": user_id, "balance": 0, "fiat_balances": {}, - "accounts": [] + "accounts": [], + "_eur_sats": 0, # SATS equivalent of EUR entries (from weight) + "_sats_paid": 0, # SATS from payment entries } - # Parse fiat amount - fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) - - # Parse SATS from weight column - sats_amount = 0 + # Parse SATS from weight column (always SATS for both entry formats) + sats_weight = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_value = weight_sum["SATS"] - sats_amount = int(Decimal(str(sats_value))) + sats_weight = int(Decimal(str(weight_sum["SATS"]))) - 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 "EUR" not in user_data[user_id]["fiat_balances"]: - user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0) - user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount + if fiat_amount != 0: + if currency not in user_data[user_id]["fiat_balances"]: + user_data[user_id]["fiat_balances"][currency] = Decimal(0) + user_data[user_id]["fiat_balances"][currency] += fiat_amount - user_data[user_id]["accounts"].append({ - "account": account_name, - "sats": sats_amount, - "eur": fiat_amount - }) + user_data[user_id]["accounts"].append({ + "account": account_name, + "sats": sats_weight, + "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)")