Compare commits
No commits in common. "f8af54f90b5b7da6af0fafdf1cfc501e851d1776" and "7173e051feed3e9aaa190b0e4942d8c280679231" have entirely different histories.
f8af54f90b
...
7173e051fe
3 changed files with 140 additions and 724 deletions
553
fava_client.py
553
fava_client.py
|
|
@ -111,16 +111,13 @@ class FavaClient:
|
|||
"""
|
||||
Get balance for a specific account (excluding pending transactions).
|
||||
|
||||
Uses sum(weight) for efficient SATS aggregation from price notation.
|
||||
|
||||
Args:
|
||||
account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- sats: int (balance in satoshis from weight column)
|
||||
- fiat: Decimal (balance in fiat currency from number column)
|
||||
- fiat_currency: str (currency code, defaults to EUR)
|
||||
- sats: int (balance in satoshis)
|
||||
- positions: dict (currency → amount with cost basis)
|
||||
|
||||
Note:
|
||||
Excludes pending transactions (flag='!') from balance calculation.
|
||||
|
|
@ -128,13 +125,12 @@ class FavaClient:
|
|||
|
||||
Example:
|
||||
balance = await fava_client.get_account_balance("Assets:Receivable:User-abc")
|
||||
# Returns: {"sats": 200000, "fiat": Decimal("150.00"), "fiat_currency": "EUR"}
|
||||
# Returns: {
|
||||
# "sats": 200000,
|
||||
# "positions": {"SATS": {"{100.00 EUR}": 200000}}
|
||||
# }
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
# Use sum(weight) for SATS and sum(number) for fiat
|
||||
# Note: BQL doesn't support != operator, so use flag = '*' to exclude pending
|
||||
query = f"SELECT sum(number), sum(weight) WHERE account = '{account_name}' AND flag = '*'"
|
||||
query = f"SELECT sum(position) WHERE account = '{account_name}' AND flag != '!'"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
|
|
@ -145,26 +141,26 @@ class FavaClient:
|
|||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data['data']['rows'] or not data['data']['rows'][0]:
|
||||
return {"sats": 0, "fiat": Decimal(0), "fiat_currency": "EUR"}
|
||||
if not data['data']['rows']:
|
||||
return {"sats": 0, "positions": {}}
|
||||
|
||||
row = data['data']['rows'][0]
|
||||
fiat_sum = row[0] if len(row) > 0 else 0
|
||||
weight_sum = row[1] if len(row) > 1 else {}
|
||||
# Fava returns: [[account, {"SATS": {cost: amount}}]]
|
||||
positions = data['data']['rows'][0][1] if data['data']['rows'] else {}
|
||||
|
||||
# Parse fiat amount
|
||||
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
|
||||
|
||||
# Parse SATS from weight column
|
||||
# Sum up all SATS positions
|
||||
total_sats = 0
|
||||
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||
sats_value = weight_sum["SATS"]
|
||||
total_sats = int(Decimal(str(sats_value)))
|
||||
if isinstance(positions, dict) and "SATS" in positions:
|
||||
sats_positions = positions["SATS"]
|
||||
if isinstance(sats_positions, dict):
|
||||
# Sum all amounts (with different cost bases)
|
||||
total_sats = sum(int(amount) for amount in sats_positions.values())
|
||||
elif isinstance(sats_positions, (int, float)):
|
||||
# Simple number (no cost basis)
|
||||
total_sats = int(sats_positions)
|
||||
|
||||
return {
|
||||
"sats": total_sats,
|
||||
"fiat": fiat_amount,
|
||||
"fiat_currency": "EUR" # Default, could be extended to detect currency
|
||||
"positions": positions
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
|
|
@ -228,61 +224,27 @@ class FavaClient:
|
|||
continue
|
||||
|
||||
import re
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = Decimal(total_price_match.group(1))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
sats_amount = int(total_price_match.group(3))
|
||||
# Try to extract EUR/USD amount first (new format)
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
# Direct EUR/USD amount (new approach)
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
if fiat_currency not in fiat_balances:
|
||||
fiat_balances[fiat_currency] = Decimal(0)
|
||||
|
||||
fiat_balances[fiat_currency] += fiat_amount
|
||||
|
||||
total_sats += sats_amount
|
||||
if account_name not in accounts_dict:
|
||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||
accounts_dict[account_name]["sats"] += sats_amount
|
||||
|
||||
elif unit_price_match:
|
||||
fiat_amount = Decimal(unit_price_match.group(1))
|
||||
fiat_currency = unit_price_match.group(2)
|
||||
sats_per_unit = Decimal(unit_price_match.group(3))
|
||||
sats_amount = int(fiat_amount * sats_per_unit)
|
||||
|
||||
if fiat_currency not in fiat_balances:
|
||||
fiat_balances[fiat_currency] = Decimal(0)
|
||||
fiat_balances[fiat_currency] += fiat_amount
|
||||
|
||||
total_sats += sats_amount
|
||||
if account_name not in accounts_dict:
|
||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||
accounts_dict[account_name]["sats"] += sats_amount
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
if fiat_currency not in fiat_balances:
|
||||
fiat_balances[fiat_currency] = Decimal(0)
|
||||
fiat_balances[fiat_currency] += fiat_amount
|
||||
|
||||
# Also track SATS equivalent from metadata if available (legacy)
|
||||
posting_meta = posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
||||
total_sats += sats_amount
|
||||
if account_name not in accounts_dict:
|
||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||
accounts_dict[account_name]["sats"] += sats_amount
|
||||
# Also track SATS equivalent from metadata if available
|
||||
posting_meta = posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
||||
total_sats += sats_amount
|
||||
if account_name not in accounts_dict:
|
||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||
accounts_dict[account_name]["sats"] += sats_amount
|
||||
|
||||
else:
|
||||
# Old format: SATS with cost/price notation - extract SATS amount
|
||||
|
|
@ -385,50 +347,24 @@ class FavaClient:
|
|||
continue
|
||||
|
||||
import re
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = Decimal(total_price_match.group(1))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
sats_amount = int(total_price_match.group(3))
|
||||
# Try to extract EUR/USD amount first (new format)
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
# Direct EUR/USD amount (new approach)
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
|
||||
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
|
||||
user_data[user_id]["balance"] += sats_amount
|
||||
|
||||
elif unit_price_match:
|
||||
fiat_amount = Decimal(unit_price_match.group(1))
|
||||
fiat_currency = unit_price_match.group(2)
|
||||
sats_per_unit = Decimal(unit_price_match.group(3))
|
||||
sats_amount = int(fiat_amount * sats_per_unit)
|
||||
|
||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
|
||||
user_data[user_id]["balance"] += sats_amount
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
|
||||
|
||||
# Also track SATS equivalent from metadata if available (legacy)
|
||||
posting_meta = posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
||||
user_data[user_id]["balance"] += sats_amount
|
||||
# Also track SATS equivalent from metadata if available
|
||||
posting_meta = posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
||||
user_data[user_id]["balance"] += sats_amount
|
||||
|
||||
else:
|
||||
# Old format: SATS with cost/price notation
|
||||
|
|
@ -778,168 +714,6 @@ class FavaClient:
|
|||
|
||||
return list(user_data.values())
|
||||
|
||||
async def get_expense_summary_bql(
|
||||
self,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
group_by: str = "account"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get expense summary using BQL, grouped by account or date.
|
||||
|
||||
Uses sum(weight) for efficient SATS aggregation from price notation.
|
||||
|
||||
Args:
|
||||
start_date: ISO format date string (YYYY-MM-DD), optional
|
||||
end_date: ISO format date string (YYYY-MM-DD), optional
|
||||
group_by: "account" (default) or "month" for grouping
|
||||
|
||||
Returns:
|
||||
List of expense summaries:
|
||||
[
|
||||
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
|
||||
{"account": "Expenses:Supplies:Kitchen", "fiat": 200.00, "sats": 220000},
|
||||
...
|
||||
]
|
||||
Or if group_by="month":
|
||||
[
|
||||
{"month": "2025-12", "fiat": 700.00, "sats": 770000},
|
||||
...
|
||||
]
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
# Build date filter
|
||||
date_filter = ""
|
||||
if start_date:
|
||||
date_filter += f" AND date >= {start_date}"
|
||||
if end_date:
|
||||
date_filter += f" AND date <= {end_date}"
|
||||
|
||||
if group_by == "month":
|
||||
query = f"""
|
||||
SELECT year, month, sum(number), sum(weight)
|
||||
WHERE account ~ 'Expenses:'
|
||||
AND flag = '*'
|
||||
{date_filter}
|
||||
GROUP BY year, month
|
||||
ORDER BY year DESC, month DESC
|
||||
"""
|
||||
else:
|
||||
query = f"""
|
||||
SELECT account, sum(number), sum(weight)
|
||||
WHERE account ~ 'Expenses:'
|
||||
AND flag = '*'
|
||||
{date_filter}
|
||||
GROUP BY account
|
||||
ORDER BY sum(weight)
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.query_bql(query)
|
||||
|
||||
summaries = []
|
||||
for row in result["rows"]:
|
||||
if group_by == "month":
|
||||
year, month, fiat_sum, weight_sum = row
|
||||
# Parse SATS from weight
|
||||
sats_amount = 0
|
||||
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||
|
||||
summaries.append({
|
||||
"month": f"{year}-{month:02d}",
|
||||
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||
"fiat_currency": "EUR",
|
||||
"sats": sats_amount
|
||||
})
|
||||
else:
|
||||
account, fiat_sum, weight_sum = row
|
||||
# Parse SATS from weight
|
||||
sats_amount = 0
|
||||
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||
|
||||
summaries.append({
|
||||
"account": account,
|
||||
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||
"fiat_currency": "EUR",
|
||||
"sats": sats_amount
|
||||
})
|
||||
|
||||
logger.info(f"BQL: Expense summary returned {len(summaries)} items")
|
||||
return summaries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting expense summary via BQL: {e}")
|
||||
raise
|
||||
|
||||
async def get_user_contributions_bql(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get total expense contributions per user using BQL.
|
||||
|
||||
Uses sum(weight) to aggregate all expenses each user has submitted
|
||||
that created liabilities (castle owes user).
|
||||
|
||||
Returns:
|
||||
List of user contribution summaries:
|
||||
[
|
||||
{
|
||||
"user_id": "cfe378b3",
|
||||
"total_fiat": 1500.00,
|
||||
"total_sats": 1650000,
|
||||
"entry_count": 25
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
# Query all expense entries that created payables, grouped by user
|
||||
query = """
|
||||
SELECT account, sum(number), sum(weight), count(number)
|
||||
WHERE account ~ 'Liabilities:Payable:User-'
|
||||
AND 'expense-entry' IN tags
|
||||
AND flag = '*'
|
||||
GROUP BY account
|
||||
ORDER BY sum(weight)
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.query_bql(query)
|
||||
|
||||
contributions = []
|
||||
for row in result["rows"]:
|
||||
account, fiat_sum, weight_sum, count = row
|
||||
|
||||
# Extract user_id from account name
|
||||
if ":User-" not in account:
|
||||
continue
|
||||
user_id = account.split(":User-")[1][:8]
|
||||
|
||||
# Parse SATS from weight (negative for liabilities)
|
||||
sats_amount = 0
|
||||
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
|
||||
|
||||
contributions.append({
|
||||
"user_id": user_id,
|
||||
"total_fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
|
||||
"fiat_currency": "EUR",
|
||||
"total_sats": sats_amount,
|
||||
"entry_count": int(count) if count else 0
|
||||
})
|
||||
|
||||
# Sort by total_sats descending (highest contributors first)
|
||||
contributions.sort(key=lambda x: x["total_sats"], reverse=True)
|
||||
|
||||
logger.info(f"BQL: Found contributions from {len(contributions)} users")
|
||||
return contributions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user contributions via BQL: {e}")
|
||||
raise
|
||||
|
||||
async def get_account_transactions(
|
||||
self,
|
||||
account_name: str,
|
||||
|
|
@ -1028,9 +802,6 @@ class FavaClient:
|
|||
"""
|
||||
Get journal entries from Fava (with entry hashes), optionally filtered by date.
|
||||
|
||||
Uses Fava's server-side 'time' parameter for efficient date filtering,
|
||||
avoiding the need to fetch all entries and filter in Python.
|
||||
|
||||
Args:
|
||||
days: If provided, only return entries from the last N days.
|
||||
If None, returns all entries (default behavior).
|
||||
|
|
@ -1055,35 +826,59 @@ class FavaClient:
|
|||
# Get entries in custom date range
|
||||
custom = await fava.get_journal_entries(start_date="2024-01-01", end_date="2024-01-31")
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
# Build query parameters for server-side filtering
|
||||
params = {}
|
||||
|
||||
# Use date range if both start_date and end_date are provided
|
||||
if start_date and end_date:
|
||||
# Fava uses "YYYY-MM-DD - YYYY-MM-DD" format for time ranges
|
||||
params["time"] = f"{start_date} - {end_date}"
|
||||
logger.info(f"Querying journal with date range: {start_date} to {end_date}")
|
||||
|
||||
# Fall back to days filter if no date range provided
|
||||
elif days is not None:
|
||||
cutoff_date = (datetime.now() - timedelta(days=days)).date()
|
||||
today = datetime.now().date()
|
||||
params["time"] = f"{cutoff_date.isoformat()} - {today.isoformat()}"
|
||||
logger.info(f"Querying journal for last {days} days (from {cutoff_date})")
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/journal", params=params)
|
||||
response = await client.get(f"{self.base_url}/journal")
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
entries = result.get("data", [])
|
||||
logger.info(f"Fava /journal returned {len(entries)} entries")
|
||||
|
||||
if params:
|
||||
logger.info(f"Fava /journal returned {len(entries)} entries (filtered)")
|
||||
else:
|
||||
logger.info(f"Fava /journal returned {len(entries)} entries (all)")
|
||||
# Filter by date range or days
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Use date range if both start_date and end_date are provided
|
||||
if start_date and end_date:
|
||||
try:
|
||||
filter_start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
filter_end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
filtered_entries = []
|
||||
for e in entries:
|
||||
entry_date_str = e.get("date")
|
||||
if entry_date_str:
|
||||
try:
|
||||
entry_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
|
||||
if filter_start <= entry_date <= filter_end:
|
||||
filtered_entries.append(e)
|
||||
except (ValueError, TypeError):
|
||||
# Include entries with invalid dates (shouldn't happen)
|
||||
filtered_entries.append(e)
|
||||
logger.info(f"Filtered to {len(filtered_entries)} entries between {start_date} and {end_date}")
|
||||
entries = filtered_entries
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid date format: {e}")
|
||||
# Return all entries if date parsing fails
|
||||
|
||||
# Fall back to days filter if no date range provided
|
||||
elif days is not None:
|
||||
cutoff_date = (datetime.now() - timedelta(days=days)).date()
|
||||
filtered_entries = []
|
||||
for e in entries:
|
||||
entry_date_str = e.get("date")
|
||||
if entry_date_str:
|
||||
try:
|
||||
entry_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
|
||||
if entry_date >= cutoff_date:
|
||||
filtered_entries.append(e)
|
||||
except (ValueError, TypeError):
|
||||
# Include entries with invalid dates (shouldn't happen)
|
||||
filtered_entries.append(e)
|
||||
logger.info(f"Filtered to {len(filtered_entries)} entries from last {days} days (cutoff: {cutoff_date})")
|
||||
entries = filtered_entries
|
||||
|
||||
# Log transactions with "Lightning payment" in narration
|
||||
lightning_entries = [e for e in entries if "Lightning payment" in e.get("narration", "")]
|
||||
logger.info(f"Found {len(lightning_entries)} Lightning payment entries in journal")
|
||||
|
||||
return entries
|
||||
|
||||
|
|
@ -1096,17 +891,18 @@ class FavaClient:
|
|||
|
||||
async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get entry source text and sha256sum for editing.
|
||||
|
||||
Uses /source_slice endpoint which returns the editable source.
|
||||
Get entry context including source text and sha256sum.
|
||||
|
||||
Args:
|
||||
entry_hash: Entry hash from get_journal_entries()
|
||||
|
||||
Returns:
|
||||
{
|
||||
"entry": {...}, # Serialized entry
|
||||
"slice": "2025-01-15 ! \"Description\"...", # Beancount source text
|
||||
"sha256sum": "abc123...", # For concurrency control
|
||||
"balances_before": {...},
|
||||
"balances_after": {...}
|
||||
}
|
||||
|
||||
Example:
|
||||
|
|
@ -1117,7 +913,7 @@ class FavaClient:
|
|||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/source_slice",
|
||||
f"{self.base_url}/context",
|
||||
params={"entry_hash": entry_hash}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
@ -1336,151 +1132,6 @@ class FavaClient:
|
|||
logger.error(f"Fava connection error: {e}")
|
||||
raise
|
||||
|
||||
async def get_unsettled_entries_bql(
|
||||
self,
|
||||
user_id: str,
|
||||
entry_type: str = "expense"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get unsettled expense or receivable entries for a user using BQL.
|
||||
|
||||
Uses BQL queries to efficiently find entries with exp-{id} or rcv-{id}
|
||||
links that don't have a corresponding settlement entry.
|
||||
|
||||
This is significantly more efficient than the legacy method as it:
|
||||
- Queries only relevant entries (not ALL journal entries)
|
||||
- Uses weight column for SATS amounts (no string parsing)
|
||||
- Filters by tags and account patterns in the database
|
||||
|
||||
Args:
|
||||
user_id: User ID (first 8 characters used for account matching)
|
||||
entry_type: "expense" (payables - castle owes user) or
|
||||
"receivable" (user owes castle)
|
||||
|
||||
Returns:
|
||||
List of unsettled entries with:
|
||||
- link: The entry's unique link (exp-xxx or rcv-xxx)
|
||||
- date: Entry date
|
||||
- narration: Description
|
||||
- fiat_amount: Amount in fiat currency (absolute value)
|
||||
- fiat_currency: Currency code
|
||||
- sats_amount: Amount in SATS (absolute value, from weight)
|
||||
- entry_hash: For potential updates
|
||||
- flag: Transaction flag
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
user_short = user_id[:8]
|
||||
link_prefix = "exp-" if entry_type == "expense" else "rcv-"
|
||||
entry_tag = "expense-entry" if entry_type == "expense" else "receivable-entry"
|
||||
|
||||
# Determine account pattern based on entry type
|
||||
if entry_type == "expense":
|
||||
account_pattern = f"Liabilities:Payable:User-{user_short}"
|
||||
else:
|
||||
account_pattern = f"Assets:Receivable:User-{user_short}"
|
||||
|
||||
try:
|
||||
# Query 1: Get all original expense/receivable entries for this user
|
||||
# These are entries with the expense-entry or receivable-entry tag
|
||||
original_query = f"""
|
||||
SELECT date, narration, account, number, weight, links,
|
||||
any_meta('entry-id') as entry_id
|
||||
WHERE account ~ '{account_pattern}'
|
||||
AND '{entry_tag}' IN tags
|
||||
AND flag = '*'
|
||||
ORDER BY date
|
||||
"""
|
||||
|
||||
original_result = await self.query_bql(original_query)
|
||||
|
||||
# Query 2: Get all settlement entries for this user
|
||||
# These are entries with the settlement tag
|
||||
settlement_query = f"""
|
||||
SELECT links
|
||||
WHERE account ~ '{account_pattern}'
|
||||
AND 'settlement' IN tags
|
||||
AND flag = '*'
|
||||
"""
|
||||
|
||||
settlement_result = await self.query_bql(settlement_query)
|
||||
|
||||
# Build set of settled links from settlement entries
|
||||
settled_links: set = set()
|
||||
for row in settlement_result["rows"]:
|
||||
links = row[0] if row else []
|
||||
if isinstance(links, list):
|
||||
for link in links:
|
||||
if link.startswith(link_prefix):
|
||||
settled_links.add(link)
|
||||
|
||||
# Process original entries and find unsettled ones
|
||||
entries_by_link: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for row in original_result["rows"]:
|
||||
date_val, narration, account, number, weight, links, entry_id = row
|
||||
|
||||
# Skip if no links
|
||||
if not links or not isinstance(links, list):
|
||||
continue
|
||||
|
||||
# Find the exp-/rcv- link
|
||||
entry_link = None
|
||||
for link in links:
|
||||
if link.startswith(link_prefix):
|
||||
entry_link = link
|
||||
break
|
||||
|
||||
if not entry_link:
|
||||
continue
|
||||
|
||||
# Skip if already settled
|
||||
if entry_link in settled_links:
|
||||
continue
|
||||
|
||||
# Skip if we already have this entry (BQL returns one row per posting)
|
||||
if entry_link in entries_by_link:
|
||||
continue
|
||||
|
||||
# Parse amounts
|
||||
fiat_amount = abs(float(number)) if number else 0.0
|
||||
fiat_currency = "EUR" # Default, could be extracted from posting
|
||||
|
||||
# Parse SATS from weight column
|
||||
sats_amount = 0
|
||||
if isinstance(weight, dict) and "SATS" in weight:
|
||||
sats_value = weight["SATS"]
|
||||
sats_amount = abs(int(Decimal(str(sats_value))))
|
||||
|
||||
# Format date as string
|
||||
date_str = str(date_val) if date_val else ""
|
||||
|
||||
entries_by_link[entry_link] = {
|
||||
"link": entry_link,
|
||||
"date": date_str,
|
||||
"narration": narration or "",
|
||||
"fiat_amount": fiat_amount,
|
||||
"fiat_currency": fiat_currency,
|
||||
"sats_amount": sats_amount,
|
||||
"entry_hash": "", # Not available from BQL, use entry_id instead
|
||||
"entry_id": entry_id or "",
|
||||
"flag": "*"
|
||||
}
|
||||
|
||||
# Convert to list and sort by date
|
||||
unsettled = list(entries_by_link.values())
|
||||
unsettled.sort(key=lambda x: x.get("date", ""))
|
||||
|
||||
logger.info(
|
||||
f"BQL: Found {len(unsettled)} unsettled {entry_type} entries for user {user_short} "
|
||||
f"(settled: {len(settled_links)})"
|
||||
)
|
||||
|
||||
return unsettled
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unsettled entries via BQL: {e}")
|
||||
raise
|
||||
|
||||
async def get_unsettled_entries(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,7 @@ window.app = Vue.createApp({
|
|||
this.receivableDialog.reference = ''
|
||||
this.receivableDialog.currency = null
|
||||
},
|
||||
async showSettleReceivableDialog(userBalance) {
|
||||
showSettleReceivableDialog(userBalance) {
|
||||
// Only show for users who owe castle (positive balance = receivable)
|
||||
if (userBalance.balance <= 0) return
|
||||
|
||||
|
|
@ -1151,30 +1151,6 @@ window.app = Vue.createApp({
|
|||
const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR')
|
||||
const fiatAmount = fiatCurrency ? Math.abs(fiatBalances[fiatCurrency]) : 0
|
||||
|
||||
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
|
||||
let allEntryLinks = []
|
||||
try {
|
||||
// Fetch receivable entries (user owes castle)
|
||||
const receivableResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
|
||||
|
||||
// Also fetch expense entries (castle owes user) - these are netted in the settlement
|
||||
const expenseResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch unsettled entries:', error)
|
||||
}
|
||||
|
||||
this.settleReceivableDialog = {
|
||||
show: true,
|
||||
user_id: userBalance.user_id,
|
||||
|
|
@ -1192,8 +1168,7 @@ window.app = Vue.createApp({
|
|||
checkWalletKey: null,
|
||||
pollIntervalId: null,
|
||||
exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate
|
||||
originalCurrency: fiatCurrency || 'BTC',
|
||||
entryLinks: allEntryLinks // Include BOTH rcv-xxx AND exp-xxx links for net settlement
|
||||
originalCurrency: fiatCurrency || 'BTC'
|
||||
}
|
||||
},
|
||||
async generateSettlementInvoice() {
|
||||
|
|
@ -1329,11 +1304,6 @@ window.app = Vue.createApp({
|
|||
payload.amount_sats = this.settleReceivableDialog.maxAmount
|
||||
}
|
||||
|
||||
// Include links to entries being settled
|
||||
if (this.settleReceivableDialog.entryLinks && this.settleReceivableDialog.entryLinks.length > 0) {
|
||||
payload.settled_entry_links = this.settleReceivableDialog.entryLinks
|
||||
}
|
||||
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/receivables/settle',
|
||||
|
|
@ -1359,7 +1329,7 @@ window.app = Vue.createApp({
|
|||
this.settleReceivableDialog.loading = false
|
||||
}
|
||||
},
|
||||
async showPayUserDialog(userBalance) {
|
||||
showPayUserDialog(userBalance) {
|
||||
// Only show for users castle owes (negative balance = payable)
|
||||
if (userBalance.balance >= 0) return
|
||||
|
||||
|
|
@ -1368,50 +1338,21 @@ window.app = Vue.createApp({
|
|||
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
||||
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
|
||||
|
||||
// Use absolute values since balance is negative (liability = castle owes user)
|
||||
const maxAmountSats = Math.abs(userBalance.balance)
|
||||
const maxAmountFiat = Math.abs(fiatAmount)
|
||||
|
||||
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
|
||||
let allEntryLinks = []
|
||||
try {
|
||||
// Fetch expense entries (castle owes user)
|
||||
const expenseResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
|
||||
|
||||
// Also fetch receivable entries (user owes castle) - these are netted in the settlement
|
||||
const receivableResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch unsettled entries:', error)
|
||||
}
|
||||
|
||||
this.payUserDialog = {
|
||||
show: true,
|
||||
user_id: userBalance.user_id,
|
||||
username: userBalance.username,
|
||||
maxAmount: maxAmountSats, // Positive sats amount castle owes
|
||||
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
|
||||
maxAmount: userBalance.balance, // Positive sats amount castle owes
|
||||
maxAmountFiat: fiatAmount, // EUR or other fiat amount
|
||||
fiatCurrency: fiatCurrency,
|
||||
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
|
||||
amount: fiatCurrency ? fiatAmount : userBalance.balance, // Default to fiat if available
|
||||
payment_method: 'lightning', // Default to lightning for paying
|
||||
description: '',
|
||||
reference: '',
|
||||
loading: false,
|
||||
paymentSuccess: false,
|
||||
exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate,
|
||||
originalCurrency: fiatCurrency || 'BTC',
|
||||
entryLinks: allEntryLinks // Include BOTH exp-xxx AND rcv-xxx links for net settlement
|
||||
exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : this.currentExchangeRate,
|
||||
originalCurrency: fiatCurrency || 'BTC'
|
||||
}
|
||||
},
|
||||
async sendLightningPayment() {
|
||||
|
|
@ -1450,23 +1391,16 @@ window.app = Vue.createApp({
|
|||
)
|
||||
|
||||
// Record the payment in Castle accounting
|
||||
const payPayload = {
|
||||
user_id: this.payUserDialog.user_id,
|
||||
amount: this.payUserDialog.amount,
|
||||
payment_method: 'lightning',
|
||||
payment_hash: paymentResponse.data.payment_hash
|
||||
}
|
||||
|
||||
// Include links to entries being settled
|
||||
if (this.payUserDialog.entryLinks && this.payUserDialog.entryLinks.length > 0) {
|
||||
payPayload.settled_entry_links = this.payUserDialog.entryLinks
|
||||
}
|
||||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/payables/pay',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payPayload
|
||||
{
|
||||
user_id: this.payUserDialog.user_id,
|
||||
amount: this.payUserDialog.amount,
|
||||
payment_method: 'lightning',
|
||||
payment_hash: paymentResponse.data.payment_hash
|
||||
}
|
||||
)
|
||||
|
||||
this.payUserDialog.paymentSuccess = true
|
||||
|
|
@ -1523,11 +1457,6 @@ window.app = Vue.createApp({
|
|||
payload.amount_sats = this.payUserDialog.maxAmount
|
||||
}
|
||||
|
||||
// Include links to entries being settled
|
||||
if (this.payUserDialog.entryLinks && this.payUserDialog.entryLinks.length > 0) {
|
||||
payload.settled_entry_links = this.payUserDialog.entryLinks
|
||||
}
|
||||
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/payables/pay',
|
||||
|
|
|
|||
212
views_api.py
212
views_api.py
|
|
@ -1,7 +1,6 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
|
@ -310,8 +309,7 @@ async def api_get_account_balance(account_id: str) -> dict:
|
|||
return {
|
||||
"account_id": account_id,
|
||||
"balance": balance_data["sats"], # Balance in satoshis
|
||||
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
|
||||
"fiat_currency": balance_data.get("fiat_currency", "EUR")
|
||||
"positions": balance_data["positions"] # Full Beancount positions with cost basis
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -510,38 +508,21 @@ async def api_get_user_entries(
|
|||
if isinstance(first_posting, dict):
|
||||
amount_str = first_posting.get("amount", "")
|
||||
|
||||
# Parse amount string: price notation, simple fiat, or legacy SATS format
|
||||
# Parse amount string: can be EUR/USD directly (new format) or "SATS {EUR}" (old format)
|
||||
if isinstance(amount_str, str) and amount_str:
|
||||
import re
|
||||
# Try EUR/USD format first (new format: "37.22 EUR")
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
# Direct fiat amount (new approach)
|
||||
fiat_amount = abs(float(fiat_match.group(1)))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = abs(float(total_price_match.group(1)))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
amount_sats = abs(int(total_price_match.group(3)))
|
||||
elif unit_price_match:
|
||||
fiat_amount = abs(float(unit_price_match.group(1)))
|
||||
fiat_currency = unit_price_match.group(2)
|
||||
sats_per_unit = float(unit_price_match.group(3))
|
||||
amount_sats = abs(int(fiat_amount * sats_per_unit))
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = abs(float(fiat_match.group(1)))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
# Get SATS from metadata (legacy)
|
||||
posting_meta = first_posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
amount_sats = abs(int(sats_equiv))
|
||||
|
||||
# Get SATS from metadata
|
||||
posting_meta = first_posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
amount_sats = abs(int(sats_equiv))
|
||||
else:
|
||||
# Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
||||
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
||||
|
|
@ -800,33 +781,17 @@ async def api_get_pending_entries(
|
|||
if isinstance(amount_str, str) and amount_str:
|
||||
import re
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
# Try EUR/USD format first (new architecture): "50.00 EUR"
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = abs(float(fiat_match.group(1)))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = abs(float(total_price_match.group(1)))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
amount_sats = abs(int(total_price_match.group(3)))
|
||||
elif unit_price_match:
|
||||
fiat_amount = abs(float(unit_price_match.group(1)))
|
||||
fiat_currency = unit_price_match.group(2)
|
||||
sats_per_unit = float(unit_price_match.group(3))
|
||||
amount_sats = abs(int(fiat_amount * sats_per_unit))
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = abs(float(fiat_match.group(1)))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
||||
# Extract sats equivalent from metadata (legacy)
|
||||
posting_meta = first_posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
amount_sats = abs(int(sats_equiv))
|
||||
# Extract sats equivalent from metadata
|
||||
posting_meta = first_posting.get("meta", {})
|
||||
sats_equiv = posting_meta.get("sats-equivalent")
|
||||
if sats_equiv:
|
||||
amount_sats = abs(int(sats_equiv))
|
||||
|
||||
else:
|
||||
# Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
||||
|
|
@ -2216,135 +2181,6 @@ async def api_get_castle_users(
|
|||
return users
|
||||
|
||||
|
||||
@castle_api_router.get("/api/v1/reports/expenses")
|
||||
async def api_expense_report(
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
group_by: str = "account",
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> dict:
|
||||
"""
|
||||
Get expense summary report using BQL.
|
||||
|
||||
Args:
|
||||
start_date: Filter from this date (YYYY-MM-DD), optional
|
||||
end_date: Filter to this date (YYYY-MM-DD), optional
|
||||
group_by: "account" (by expense category) or "month" (by month)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"summary": [
|
||||
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
|
||||
...
|
||||
],
|
||||
"total_fiat": 1500.00,
|
||||
"total_sats": 1650000,
|
||||
"fiat_currency": "EUR",
|
||||
"group_by": "account",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-12-31"
|
||||
}
|
||||
|
||||
Admin only.
|
||||
"""
|
||||
from .fava_client import get_fava_client
|
||||
|
||||
if group_by not in ["account", "month"]:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="group_by must be 'account' or 'month'"
|
||||
)
|
||||
|
||||
fava = get_fava_client()
|
||||
summaries = await fava.get_expense_summary_bql(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
group_by=group_by
|
||||
)
|
||||
|
||||
# Calculate totals
|
||||
total_fiat = sum(s.get("fiat", 0) for s in summaries)
|
||||
total_sats = sum(s.get("sats", 0) for s in summaries)
|
||||
|
||||
return {
|
||||
"summary": summaries,
|
||||
"total_fiat": total_fiat,
|
||||
"total_sats": total_sats,
|
||||
"fiat_currency": "EUR",
|
||||
"group_by": group_by,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"count": len(summaries)
|
||||
}
|
||||
|
||||
|
||||
@castle_api_router.get("/api/v1/reports/contributions")
|
||||
async def api_contributions_report(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> dict:
|
||||
"""
|
||||
Get user contribution report using BQL.
|
||||
|
||||
Shows total expenses submitted by each user (creating payables).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"user_id": "cfe378b3",
|
||||
"username": "alice",
|
||||
"total_fiat": 1500.00,
|
||||
"total_sats": 1650000,
|
||||
"entry_count": 25
|
||||
},
|
||||
...
|
||||
],
|
||||
"total_fiat": 5000.00,
|
||||
"total_sats": 5500000,
|
||||
"fiat_currency": "EUR",
|
||||
"user_count": 5
|
||||
}
|
||||
|
||||
Admin only.
|
||||
"""
|
||||
from lnbits.core.crud.users import get_user
|
||||
from .fava_client import get_fava_client
|
||||
|
||||
fava = get_fava_client()
|
||||
contributions = await fava.get_user_contributions_bql()
|
||||
|
||||
# Enrich with usernames
|
||||
for contrib in contributions:
|
||||
user_id = contrib["user_id"]
|
||||
# Try to find full user_id from wallet settings
|
||||
settings = await get_all_user_wallet_settings()
|
||||
full_user_id = None
|
||||
for s in settings:
|
||||
if s.id.startswith(user_id):
|
||||
full_user_id = s.id
|
||||
break
|
||||
|
||||
if full_user_id:
|
||||
user = await get_user(full_user_id)
|
||||
contrib["username"] = user.username if user and user.username else None
|
||||
contrib["full_user_id"] = full_user_id
|
||||
else:
|
||||
contrib["username"] = None
|
||||
contrib["full_user_id"] = None
|
||||
|
||||
# Calculate totals
|
||||
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
|
||||
total_sats = sum(c.get("total_sats", 0) for c in contributions)
|
||||
|
||||
return {
|
||||
"contributions": contributions,
|
||||
"total_fiat": total_fiat,
|
||||
"total_sats": total_sats,
|
||||
"fiat_currency": "EUR",
|
||||
"user_count": len(contributions)
|
||||
}
|
||||
|
||||
|
||||
@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries")
|
||||
async def api_get_unsettled_entries(
|
||||
user_id: str,
|
||||
|
|
@ -2395,7 +2231,7 @@ async def api_get_unsettled_entries(
|
|||
)
|
||||
|
||||
fava = get_fava_client()
|
||||
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
|
||||
unsettled = await fava.get_unsettled_entries(user_id, entry_type)
|
||||
|
||||
# Calculate totals
|
||||
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue