Compare commits

..

No commits in common. "f8af54f90b5b7da6af0fafdf1cfc501e851d1776" and "7173e051feed3e9aaa190b0e4942d8c280679231" have entirely different histories.

3 changed files with 140 additions and 724 deletions

View file

@ -111,16 +111,13 @@ 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 from weight column) - sats: int (balance in satoshis)
- fiat: Decimal (balance in fiat currency from number column) - positions: dict (currency amount with cost basis)
- fiat_currency: str (currency code, defaults to EUR)
Note: Note:
Excludes pending transactions (flag='!') from balance calculation. Excludes pending transactions (flag='!') from balance calculation.
@ -128,13 +125,12 @@ 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: {"sats": 200000, "fiat": Decimal("150.00"), "fiat_currency": "EUR"} # Returns: {
# "sats": 200000,
# "positions": {"SATS": {"{100.00 EUR}": 200000}}
# }
""" """
from decimal import Decimal query = f"SELECT sum(position) WHERE account = '{account_name}' AND flag != '!'"
# 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:
@ -145,26 +141,26 @@ class FavaClient:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if not data['data']['rows'] or not data['data']['rows'][0]: if not data['data']['rows']:
return {"sats": 0, "fiat": Decimal(0), "fiat_currency": "EUR"} return {"sats": 0, "positions": {}}
row = data['data']['rows'][0] # Fava returns: [[account, {"SATS": {cost: amount}}]]
fiat_sum = row[0] if len(row) > 0 else 0 positions = data['data']['rows'][0][1] if data['data']['rows'] else {}
weight_sum = row[1] if len(row) > 1 else {}
# Parse fiat amount # Sum up all SATS positions
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(weight_sum, dict) and "SATS" in weight_sum: if isinstance(positions, dict) and "SATS" in positions:
sats_value = weight_sum["SATS"] sats_positions = positions["SATS"]
total_sats = int(Decimal(str(sats_value))) 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 { return {
"sats": total_sats, "sats": total_sats,
"fiat": fiat_amount, "positions": positions
"fiat_currency": "EUR" # Default, could be extended to detect currency
} }
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@ -228,61 +224,27 @@ class FavaClient:
continue continue
import re import re
# Try to extract EUR/USD amount first (new format)
# 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" # Direct EUR/USD amount (new approach)
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) fiat_amount = Decimal(fiat_match.group(1))
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
total_sats += sats_amount # Also track SATS equivalent from metadata if available
if account_name not in accounts_dict: posting_meta = posting.get("meta", {})
accounts_dict[account_name] = {"account": account_name, "sats": 0} sats_equiv = posting_meta.get("sats-equivalent")
accounts_dict[account_name]["sats"] += sats_amount if sats_equiv:
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
elif unit_price_match: total_sats += sats_amount
fiat_amount = Decimal(unit_price_match.group(1)) if account_name not in accounts_dict:
fiat_currency = unit_price_match.group(2) accounts_dict[account_name] = {"account": account_name, "sats": 0}
sats_per_unit = Decimal(unit_price_match.group(3)) accounts_dict[account_name]["sats"] += sats_amount
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
@ -385,50 +347,24 @@ class FavaClient:
continue continue
import re import re
# Try to extract EUR/USD amount first (new format)
# 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" # Direct EUR/USD amount (new approach)
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) fiat_amount = Decimal(fiat_match.group(1))
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
elif unit_price_match: # Also track SATS equivalent from metadata if available
fiat_amount = Decimal(unit_price_match.group(1)) posting_meta = posting.get("meta", {})
fiat_currency = unit_price_match.group(2) sats_equiv = posting_meta.get("sats-equivalent")
sats_per_unit = Decimal(unit_price_match.group(3)) if sats_equiv:
sats_amount = int(fiat_amount * sats_per_unit) sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
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
@ -778,168 +714,6 @@ 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,
@ -1028,9 +802,6 @@ 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).
@ -1055,35 +826,59 @@ 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", params=params) response = await client.get(f"{self.base_url}/journal")
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")
if params: # Filter by date range or days
logger.info(f"Fava /journal returned {len(entries)} entries (filtered)") from datetime import datetime, timedelta
else:
logger.info(f"Fava /journal returned {len(entries)} entries (all)") # 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 return entries
@ -1096,17 +891,18 @@ 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 source text and sha256sum for editing. Get entry context including source text and sha256sum.
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:
@ -1117,7 +913,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}/source_slice", f"{self.base_url}/context",
params={"entry_hash": entry_hash} params={"entry_hash": entry_hash}
) )
response.raise_for_status() response.raise_for_status()
@ -1336,151 +1132,6 @@ 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,

View file

@ -1137,7 +1137,7 @@ window.app = Vue.createApp({
this.receivableDialog.reference = '' this.receivableDialog.reference = ''
this.receivableDialog.currency = null this.receivableDialog.currency = null
}, },
async showSettleReceivableDialog(userBalance) { 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,30 +1151,6 @@ 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,
@ -1192,8 +1168,7 @@ 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() {
@ -1329,11 +1304,6 @@ 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',
@ -1359,7 +1329,7 @@ window.app = Vue.createApp({
this.settleReceivableDialog.loading = false this.settleReceivableDialog.loading = false
} }
}, },
async showPayUserDialog(userBalance) { 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
@ -1368,50 +1338,21 @@ 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: maxAmountSats, // Positive sats amount castle owes maxAmount: userBalance.balance, // Positive sats amount castle owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) maxAmountFiat: fiatAmount, // EUR or other fiat amount
fiatCurrency: fiatCurrency, 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 payment_method: 'lightning', // Default to lightning for paying
description: '', description: '',
reference: '', reference: '',
loading: false, loading: false,
paymentSuccess: false, paymentSuccess: false,
exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate, exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : 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() {
@ -1450,23 +1391,16 @@ 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
@ -1523,11 +1457,6 @@ 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',

View file

@ -1,7 +1,6 @@
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
@ -310,8 +309,7 @@ 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
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount "positions": balance_data["positions"] # Full Beancount positions with cost basis
"fiat_currency": balance_data.get("fiat_currency", "EUR")
} }
@ -510,38 +508,21 @@ 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: 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: 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)
# Try total price notation: "50.00 EUR @@ 50000 SATS" # Get SATS from metadata
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) posting_meta = first_posting.get("meta", {})
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" sats_equiv = posting_meta.get("sats-equivalent")
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) if sats_equiv:
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)
@ -800,33 +781,17 @@ 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 total price notation: "50.00 EUR @@ 50000 SATS" # Try EUR/USD format first (new architecture): "50.00 EUR"
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) fiat_amount = abs(float(fiat_match.group(1)))
fiat_currency = fiat_match.group(2)
if total_price_match: # Extract sats equivalent from metadata
fiat_amount = abs(float(total_price_match.group(1))) posting_meta = first_posting.get("meta", {})
fiat_currency = total_price_match.group(2) sats_equiv = posting_meta.get("sats-equivalent")
amount_sats = abs(int(total_price_match.group(3))) if sats_equiv:
elif unit_price_match: amount_sats = abs(int(sats_equiv))
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"
@ -2216,135 +2181,6 @@ 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,
@ -2395,7 +2231,7 @@ async def api_get_unsettled_entries(
) )
fava = get_fava_client() 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 # 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)