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:
parent
c4f784360d
commit
b06c53c40f
1 changed files with 96 additions and 57 deletions
153
fava_client.py
153
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)")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue