diff --git a/fava_client.py b/fava_client.py index 601907b..f7f9983 100644 --- a/fava_client.py +++ b/fava_client.py @@ -111,13 +111,16 @@ 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) - - positions: dict (currency → amount with cost basis) + - 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) Note: Excludes pending transactions (flag='!') from balance calculation. @@ -125,12 +128,13 @@ class FavaClient: Example: balance = await fava_client.get_account_balance("Assets:Receivable:User-abc") - # Returns: { - # "sats": 200000, - # "positions": {"SATS": {"{100.00 EUR}": 200000}} - # } + # Returns: {"sats": 200000, "fiat": Decimal("150.00"), "fiat_currency": "EUR"} """ - 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: async with httpx.AsyncClient(timeout=self.timeout) as client: @@ -141,26 +145,26 @@ class FavaClient: response.raise_for_status() data = response.json() - if not data['data']['rows']: - return {"sats": 0, "positions": {}} + if not data['data']['rows'] or not data['data']['rows'][0]: + return {"sats": 0, "fiat": Decimal(0), "fiat_currency": "EUR"} - # Fava returns: [[account, {"SATS": {cost: amount}}]] - positions = data['data']['rows'][0][1] if data['data']['rows'] else {} + row = data['data']['rows'][0] + 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 - 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) + if isinstance(weight_sum, dict) and "SATS" in weight_sum: + sats_value = weight_sum["SATS"] + total_sats = int(Decimal(str(sats_value))) return { "sats": total_sats, - "positions": positions + "fiat": fiat_amount, + "fiat_currency": "EUR" # Default, could be extended to detect currency } except httpx.HTTPStatusError as e: @@ -224,27 +228,61 @@ class FavaClient: continue import re - # 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) + + # 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)) 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 - 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 + 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 else: # Old format: SATS with cost/price notation - extract SATS amount @@ -347,24 +385,50 @@ class FavaClient: continue import re - # 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) + + # 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)) 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 - # 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 + 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 else: # Old format: SATS with cost/price notation @@ -714,6 +778,168 @@ 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, @@ -802,6 +1028,9 @@ 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). @@ -826,59 +1055,35 @@ 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") + response = await client.get(f"{self.base_url}/journal", params=params) response.raise_for_status() result = response.json() entries = result.get("data", []) - logger.info(f"Fava /journal returned {len(entries)} entries") - # 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") + if params: + logger.info(f"Fava /journal returned {len(entries)} entries (filtered)") + else: + logger.info(f"Fava /journal returned {len(entries)} entries (all)") return entries @@ -891,18 +1096,17 @@ class FavaClient: 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: 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: @@ -913,7 +1117,7 @@ class FavaClient: try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( - f"{self.base_url}/context", + f"{self.base_url}/source_slice", params={"entry_hash": entry_hash} ) response.raise_for_status() @@ -1132,6 +1336,151 @@ 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, diff --git a/static/js/index.js b/static/js/index.js index 318483b..3fc736e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1137,7 +1137,7 @@ window.app = Vue.createApp({ this.receivableDialog.reference = '' this.receivableDialog.currency = null }, - showSettleReceivableDialog(userBalance) { + async showSettleReceivableDialog(userBalance) { // Only show for users who owe castle (positive balance = receivable) 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 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, @@ -1168,7 +1192,8 @@ 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' + originalCurrency: fiatCurrency || 'BTC', + entryLinks: allEntryLinks // Include BOTH rcv-xxx AND exp-xxx links for net settlement } }, async generateSettlementInvoice() { @@ -1304,6 +1329,11 @@ 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', @@ -1329,7 +1359,7 @@ window.app = Vue.createApp({ this.settleReceivableDialog.loading = false } }, - showPayUserDialog(userBalance) { + async showPayUserDialog(userBalance) { // Only show for users castle owes (negative balance = payable) if (userBalance.balance >= 0) return @@ -1338,21 +1368,50 @@ 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: userBalance.balance, // Positive sats amount castle owes - maxAmountFiat: fiatAmount, // EUR or other fiat amount + maxAmount: maxAmountSats, // Positive sats amount castle owes + maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) 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 description: '', reference: '', loading: false, paymentSuccess: false, - exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : this.currentExchangeRate, - originalCurrency: fiatCurrency || 'BTC' + exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate, + originalCurrency: fiatCurrency || 'BTC', + entryLinks: allEntryLinks // Include BOTH exp-xxx AND rcv-xxx links for net settlement } }, async sendLightningPayment() { @@ -1391,16 +1450,23 @@ 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, - { - user_id: this.payUserDialog.user_id, - amount: this.payUserDialog.amount, - payment_method: 'lightning', - payment_hash: paymentResponse.data.payment_hash - } + payPayload ) this.payUserDialog.paymentSuccess = true @@ -1457,6 +1523,11 @@ 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', diff --git a/views_api.py b/views_api.py index 814d83f..fdb3c18 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,7 @@ 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 @@ -309,7 +310,8 @@ async def api_get_account_balance(account_id: str) -> dict: return { "account_id": account_id, "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): 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: 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 - posting_meta = first_posting.get("meta", {}) - sats_equiv = posting_meta.get("sats-equivalent") - if sats_equiv: - amount_sats = abs(int(sats_equiv)) + # 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)) + else: # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" 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: import re - # 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) + # 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) - # 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)) + 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)) else: # 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 +@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, @@ -2231,7 +2395,7 @@ async def api_get_unsettled_entries( ) 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 total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)