Compare commits
10 commits
7173e051fe
...
f8af54f90b
| Author | SHA1 | Date | |
|---|---|---|---|
| f8af54f90b | |||
| 7362d6292e | |||
| 1ae5c8c927 | |||
| 7dabe8700d | |||
| addf4cd05f | |||
| 49d18c3e73 | |||
| 048d19f90b | |||
| 55df2b36e0 | |||
| 116355b502 | |||
| 913e4705b1 |
3 changed files with 724 additions and 140 deletions
553
fava_client.py
553
fava_client.py
|
|
@ -111,13 +111,16 @@ class FavaClient:
|
||||||
"""
|
"""
|
||||||
Get balance for a specific account (excluding pending transactions).
|
Get balance for a specific account (excluding pending transactions).
|
||||||
|
|
||||||
|
Uses sum(weight) for efficient SATS aggregation from price notation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
|
account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with:
|
Dict with:
|
||||||
- sats: int (balance in satoshis)
|
- sats: int (balance in satoshis from weight column)
|
||||||
- positions: dict (currency → amount with cost basis)
|
- fiat: Decimal (balance in fiat currency from number column)
|
||||||
|
- fiat_currency: str (currency code, defaults to EUR)
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
Excludes pending transactions (flag='!') from balance calculation.
|
Excludes pending transactions (flag='!') from balance calculation.
|
||||||
|
|
@ -125,12 +128,13 @@ class FavaClient:
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
balance = await fava_client.get_account_balance("Assets:Receivable:User-abc")
|
balance = await fava_client.get_account_balance("Assets:Receivable:User-abc")
|
||||||
# Returns: {
|
# Returns: {"sats": 200000, "fiat": Decimal("150.00"), "fiat_currency": "EUR"}
|
||||||
# "sats": 200000,
|
|
||||||
# "positions": {"SATS": {"{100.00 EUR}": 200000}}
|
|
||||||
# }
|
|
||||||
"""
|
"""
|
||||||
query = f"SELECT sum(position) WHERE account = '{account_name}' AND flag != '!'"
|
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 = '*'"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
|
@ -141,26 +145,26 @@ class FavaClient:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if not data['data']['rows']:
|
if not data['data']['rows'] or not data['data']['rows'][0]:
|
||||||
return {"sats": 0, "positions": {}}
|
return {"sats": 0, "fiat": Decimal(0), "fiat_currency": "EUR"}
|
||||||
|
|
||||||
# Fava returns: [[account, {"SATS": {cost: amount}}]]
|
row = data['data']['rows'][0]
|
||||||
positions = data['data']['rows'][0][1] if data['data']['rows'] else {}
|
fiat_sum = row[0] if len(row) > 0 else 0
|
||||||
|
weight_sum = row[1] if len(row) > 1 else {}
|
||||||
|
|
||||||
# Sum up all SATS positions
|
# Parse fiat amount
|
||||||
|
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
|
||||||
|
|
||||||
|
# Parse SATS from weight column
|
||||||
total_sats = 0
|
total_sats = 0
|
||||||
if isinstance(positions, dict) and "SATS" in positions:
|
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
|
||||||
sats_positions = positions["SATS"]
|
sats_value = weight_sum["SATS"]
|
||||||
if isinstance(sats_positions, dict):
|
total_sats = int(Decimal(str(sats_value)))
|
||||||
# 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 {
|
return {
|
||||||
"sats": total_sats,
|
"sats": total_sats,
|
||||||
"positions": positions
|
"fiat": fiat_amount,
|
||||||
|
"fiat_currency": "EUR" # Default, could be extended to detect currency
|
||||||
}
|
}
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
|
|
@ -224,27 +228,61 @@ class FavaClient:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
import re
|
import re
|
||||||
# Try to extract EUR/USD amount first (new format)
|
|
||||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||||
# Direct EUR/USD amount (new approach)
|
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||||
fiat_amount = Decimal(fiat_match.group(1))
|
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||||
fiat_currency = fiat_match.group(2)
|
|
||||||
|
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))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
fiat_balances[fiat_currency] += fiat_amount
|
fiat_balances[fiat_currency] += fiat_amount
|
||||||
|
|
||||||
# Also track SATS equivalent from metadata if available
|
total_sats += sats_amount
|
||||||
posting_meta = posting.get("meta", {})
|
if account_name not in accounts_dict:
|
||||||
sats_equiv = posting_meta.get("sats-equivalent")
|
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||||
if sats_equiv:
|
accounts_dict[account_name]["sats"] += sats_amount
|
||||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
|
||||||
total_sats += sats_amount
|
elif unit_price_match:
|
||||||
if account_name not in accounts_dict:
|
fiat_amount = Decimal(unit_price_match.group(1))
|
||||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
fiat_currency = unit_price_match.group(2)
|
||||||
accounts_dict[account_name]["sats"] += sats_amount
|
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
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Old format: SATS with cost/price notation - extract SATS amount
|
# Old format: SATS with cost/price notation - extract SATS amount
|
||||||
|
|
@ -347,24 +385,50 @@ class FavaClient:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
import re
|
import re
|
||||||
# Try to extract EUR/USD amount first (new format)
|
|
||||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||||
# Direct EUR/USD amount (new approach)
|
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||||
fiat_amount = Decimal(fiat_match.group(1))
|
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||||
fiat_currency = fiat_match.group(2)
|
|
||||||
|
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))
|
||||||
|
|
||||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
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] = Decimal(0)
|
||||||
|
|
||||||
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
|
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
|
||||||
|
user_data[user_id]["balance"] += sats_amount
|
||||||
|
|
||||||
# Also track SATS equivalent from metadata if available
|
elif unit_price_match:
|
||||||
posting_meta = posting.get("meta", {})
|
fiat_amount = Decimal(unit_price_match.group(1))
|
||||||
sats_equiv = posting_meta.get("sats-equivalent")
|
fiat_currency = unit_price_match.group(2)
|
||||||
if sats_equiv:
|
sats_per_unit = Decimal(unit_price_match.group(3))
|
||||||
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
sats_amount = int(fiat_amount * sats_per_unit)
|
||||||
user_data[user_id]["balance"] += sats_amount
|
|
||||||
|
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
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Old format: SATS with cost/price notation
|
# Old format: SATS with cost/price notation
|
||||||
|
|
@ -714,6 +778,168 @@ class FavaClient:
|
||||||
|
|
||||||
return list(user_data.values())
|
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(
|
async def get_account_transactions(
|
||||||
self,
|
self,
|
||||||
account_name: str,
|
account_name: str,
|
||||||
|
|
@ -802,6 +1028,9 @@ class FavaClient:
|
||||||
"""
|
"""
|
||||||
Get journal entries from Fava (with entry hashes), optionally filtered by date.
|
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:
|
Args:
|
||||||
days: If provided, only return entries from the last N days.
|
days: If provided, only return entries from the last N days.
|
||||||
If None, returns all entries (default behavior).
|
If None, returns all entries (default behavior).
|
||||||
|
|
@ -826,59 +1055,35 @@ class FavaClient:
|
||||||
# Get entries in custom date range
|
# Get entries in custom date range
|
||||||
custom = await fava.get_journal_entries(start_date="2024-01-01", end_date="2024-01-31")
|
custom = await fava.get_journal_entries(start_date="2024-01-01", end_date="2024-01-31")
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
try:
|
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:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.get(f"{self.base_url}/journal")
|
response = await client.get(f"{self.base_url}/journal", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
entries = result.get("data", [])
|
entries = result.get("data", [])
|
||||||
logger.info(f"Fava /journal returned {len(entries)} entries")
|
|
||||||
|
|
||||||
# Filter by date range or days
|
if params:
|
||||||
from datetime import datetime, timedelta
|
logger.info(f"Fava /journal returned {len(entries)} entries (filtered)")
|
||||||
|
else:
|
||||||
# Use date range if both start_date and end_date are provided
|
logger.info(f"Fava /journal returned {len(entries)} entries (all)")
|
||||||
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
|
return entries
|
||||||
|
|
||||||
|
|
@ -891,18 +1096,17 @@ class FavaClient:
|
||||||
|
|
||||||
async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
|
async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get entry context including source text and sha256sum.
|
Get entry source text and sha256sum for editing.
|
||||||
|
|
||||||
|
Uses /source_slice endpoint which returns the editable source.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entry_hash: Entry hash from get_journal_entries()
|
entry_hash: Entry hash from get_journal_entries()
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"entry": {...}, # Serialized entry
|
|
||||||
"slice": "2025-01-15 ! \"Description\"...", # Beancount source text
|
"slice": "2025-01-15 ! \"Description\"...", # Beancount source text
|
||||||
"sha256sum": "abc123...", # For concurrency control
|
"sha256sum": "abc123...", # For concurrency control
|
||||||
"balances_before": {...},
|
|
||||||
"balances_after": {...}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
@ -913,7 +1117,7 @@ class FavaClient:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{self.base_url}/context",
|
f"{self.base_url}/source_slice",
|
||||||
params={"entry_hash": entry_hash}
|
params={"entry_hash": entry_hash}
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -1132,6 +1336,151 @@ class FavaClient:
|
||||||
logger.error(f"Fava connection error: {e}")
|
logger.error(f"Fava connection error: {e}")
|
||||||
raise
|
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(
|
async def get_unsettled_entries(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1137,7 +1137,7 @@ window.app = Vue.createApp({
|
||||||
this.receivableDialog.reference = ''
|
this.receivableDialog.reference = ''
|
||||||
this.receivableDialog.currency = null
|
this.receivableDialog.currency = null
|
||||||
},
|
},
|
||||||
showSettleReceivableDialog(userBalance) {
|
async showSettleReceivableDialog(userBalance) {
|
||||||
// Only show for users who owe castle (positive balance = receivable)
|
// Only show for users who owe castle (positive balance = receivable)
|
||||||
if (userBalance.balance <= 0) return
|
if (userBalance.balance <= 0) return
|
||||||
|
|
||||||
|
|
@ -1151,6 +1151,30 @@ window.app = Vue.createApp({
|
||||||
const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR')
|
const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR')
|
||||||
const fiatAmount = fiatCurrency ? Math.abs(fiatBalances[fiatCurrency]) : 0
|
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 = {
|
this.settleReceivableDialog = {
|
||||||
show: true,
|
show: true,
|
||||||
user_id: userBalance.user_id,
|
user_id: userBalance.user_id,
|
||||||
|
|
@ -1168,7 +1192,8 @@ window.app = Vue.createApp({
|
||||||
checkWalletKey: null,
|
checkWalletKey: null,
|
||||||
pollIntervalId: null,
|
pollIntervalId: null,
|
||||||
exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate
|
exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate
|
||||||
originalCurrency: fiatCurrency || 'BTC'
|
originalCurrency: fiatCurrency || 'BTC',
|
||||||
|
entryLinks: allEntryLinks // Include BOTH rcv-xxx AND exp-xxx links for net settlement
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async generateSettlementInvoice() {
|
async generateSettlementInvoice() {
|
||||||
|
|
@ -1304,6 +1329,11 @@ window.app = Vue.createApp({
|
||||||
payload.amount_sats = this.settleReceivableDialog.maxAmount
|
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(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/castle/api/v1/receivables/settle',
|
'/castle/api/v1/receivables/settle',
|
||||||
|
|
@ -1329,7 +1359,7 @@ window.app = Vue.createApp({
|
||||||
this.settleReceivableDialog.loading = false
|
this.settleReceivableDialog.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showPayUserDialog(userBalance) {
|
async showPayUserDialog(userBalance) {
|
||||||
// Only show for users castle owes (negative balance = payable)
|
// Only show for users castle owes (negative balance = payable)
|
||||||
if (userBalance.balance >= 0) return
|
if (userBalance.balance >= 0) return
|
||||||
|
|
||||||
|
|
@ -1338,21 +1368,50 @@ window.app = Vue.createApp({
|
||||||
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
||||||
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
|
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 = {
|
this.payUserDialog = {
|
||||||
show: true,
|
show: true,
|
||||||
user_id: userBalance.user_id,
|
user_id: userBalance.user_id,
|
||||||
username: userBalance.username,
|
username: userBalance.username,
|
||||||
maxAmount: userBalance.balance, // Positive sats amount castle owes
|
maxAmount: maxAmountSats, // Positive sats amount castle owes
|
||||||
maxAmountFiat: fiatAmount, // EUR or other fiat amount
|
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
|
||||||
fiatCurrency: fiatCurrency,
|
fiatCurrency: fiatCurrency,
|
||||||
amount: fiatCurrency ? fiatAmount : userBalance.balance, // Default to fiat if available
|
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
|
||||||
payment_method: 'lightning', // Default to lightning for paying
|
payment_method: 'lightning', // Default to lightning for paying
|
||||||
description: '',
|
description: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
paymentSuccess: false,
|
paymentSuccess: false,
|
||||||
exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : this.currentExchangeRate,
|
exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate,
|
||||||
originalCurrency: fiatCurrency || 'BTC'
|
originalCurrency: fiatCurrency || 'BTC',
|
||||||
|
entryLinks: allEntryLinks // Include BOTH exp-xxx AND rcv-xxx links for net settlement
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async sendLightningPayment() {
|
async sendLightningPayment() {
|
||||||
|
|
@ -1391,16 +1450,23 @@ window.app = Vue.createApp({
|
||||||
)
|
)
|
||||||
|
|
||||||
// Record the payment in Castle accounting
|
// 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(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/castle/api/v1/payables/pay',
|
'/castle/api/v1/payables/pay',
|
||||||
this.g.user.wallets[0].adminkey,
|
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
|
this.payUserDialog.paymentSuccess = true
|
||||||
|
|
@ -1457,6 +1523,11 @@ window.app = Vue.createApp({
|
||||||
payload.amount_sats = this.payUserDialog.maxAmount
|
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(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/castle/api/v1/payables/pay',
|
'/castle/api/v1/payables/pay',
|
||||||
|
|
|
||||||
212
views_api.py
212
views_api.py
|
|
@ -1,6 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -309,7 +310,8 @@ async def api_get_account_balance(account_id: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
"balance": balance_data["sats"], # Balance in satoshis
|
"balance": balance_data["sats"], # Balance in satoshis
|
||||||
"positions": balance_data["positions"] # Full Beancount positions with cost basis
|
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
|
||||||
|
"fiat_currency": balance_data.get("fiat_currency", "EUR")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -508,21 +510,38 @@ async def api_get_user_entries(
|
||||||
if isinstance(first_posting, dict):
|
if isinstance(first_posting, dict):
|
||||||
amount_str = first_posting.get("amount", "")
|
amount_str = first_posting.get("amount", "")
|
||||||
|
|
||||||
# Parse amount string: can be EUR/USD directly (new format) or "SATS {EUR}" (old format)
|
# Parse amount string: price notation, simple fiat, or legacy SATS format
|
||||||
if isinstance(amount_str, str) and amount_str:
|
if isinstance(amount_str, str) and amount_str:
|
||||||
import re
|
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)
|
|
||||||
|
|
||||||
# Get SATS from metadata
|
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||||
posting_meta = first_posting.get("meta", {})
|
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str)
|
||||||
sats_equiv = posting_meta.get("sats-equivalent")
|
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||||
if sats_equiv:
|
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||||
amount_sats = abs(int(sats_equiv))
|
|
||||||
|
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))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
# Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
||||||
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
||||||
|
|
@ -781,17 +800,33 @@ async def api_get_pending_entries(
|
||||||
if isinstance(amount_str, str) and amount_str:
|
if isinstance(amount_str, str) and amount_str:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Try EUR/USD format first (new architecture): "50.00 EUR"
|
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str)
|
||||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||||
fiat_amount = abs(float(fiat_match.group(1)))
|
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||||
fiat_currency = fiat_match.group(2)
|
|
||||||
|
|
||||||
# Extract sats equivalent from metadata
|
if total_price_match:
|
||||||
posting_meta = first_posting.get("meta", {})
|
fiat_amount = abs(float(total_price_match.group(1)))
|
||||||
sats_equiv = posting_meta.get("sats-equivalent")
|
fiat_currency = total_price_match.group(2)
|
||||||
if sats_equiv:
|
amount_sats = abs(int(total_price_match.group(3)))
|
||||||
amount_sats = abs(int(sats_equiv))
|
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))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
# Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS"
|
||||||
|
|
@ -2181,6 +2216,135 @@ async def api_get_castle_users(
|
||||||
return 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")
|
@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries")
|
||||||
async def api_get_unsettled_entries(
|
async def api_get_unsettled_entries(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|
@ -2231,7 +2395,7 @@ async def api_get_unsettled_entries(
|
||||||
)
|
)
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
unsettled = await fava.get_unsettled_entries(user_id, entry_type)
|
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
|
||||||
|
|
||||||
# Calculate totals
|
# Calculate totals
|
||||||
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
|
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue