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