From 913e4705b1343499995a2c59993fa559ee7afee7 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 00:19:49 +0100 Subject: [PATCH 01/10] Fix amount parsing to handle both @ and @@ SATS notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending expense entries use per-unit price notation (@ SATS) while migrated entries use total price notation (@@ SATS). Formats handled: - "50.00 EUR @@ 50000 SATS" - total price (multiply = amount) - "50.00 EUR @ 1000.5 SATS" - per-unit price (multiply for total) - "50.00 EUR" with metadata - legacy format - "50000 SATS" - old SATS-first format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 118 +++++++++++++++++++++++++++++++++++++------------ views_api.py | 77 +++++++++++++++++++++++--------- 2 files changed, 144 insertions(+), 51 deletions(-) diff --git a/fava_client.py b/fava_client.py index 601907b..5c66593 100644 --- a/fava_client.py +++ b/fava_client.py @@ -224,27 +224,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 +381,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 diff --git a/views_api.py b/views_api.py index 814d83f..cc1b614 100644 --- a/views_api.py +++ b/views_api.py @@ -508,21 +508,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 +798,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" From 116355b502581500080f6c0201a305d505e4797d Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 00:35:22 +0100 Subject: [PATCH 02/10] Fix get_entry_context to use /source_slice endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /context endpoint returns entry metadata but not the editable source. The /source_slice endpoint returns the actual source text and sha256sum needed for approving/rejecting entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/fava_client.py b/fava_client.py index 5c66593..dd83699 100644 --- a/fava_client.py +++ b/fava_client.py @@ -951,18 +951,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: @@ -973,7 +972,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() From 55df2b36e0001ee0b0705c244fd740d66a88fef0 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 00:54:51 +0100 Subject: [PATCH 03/10] Fix Pay User dialog showing negative values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Math.abs() to display liability amounts as positive values in the Pay User dialog. Liabilities are stored as negative (castle owes user) but should display as positive when framed as "Amount Castle Owes". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- static/js/index.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 318483b..a0b38ba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1338,20 +1338,24 @@ 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) + 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, + exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate, originalCurrency: fiatCurrency || 'BTC' } }, From 048d19f90b7b76618812c03d35b60c8d238f910b Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:08:38 +0100 Subject: [PATCH 04/10] Add BQL-optimized get_unsettled_entries_bql method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inefficient approach that fetched ALL journal entries with targeted BQL queries that: - Filter by account pattern and tags in the database - Use weight column for SATS amounts (no string parsing) - Query only expense/receivable entries for the specific user This significantly reduces data transfer and processing overhead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 145 +++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 2 +- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/fava_client.py b/fava_client.py index dd83699..76aa022 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1191,6 +1191,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/views_api.py b/views_api.py index cc1b614..86b96e6 100644 --- a/views_api.py +++ b/views_api.py @@ -2264,7 +2264,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) From 49d18c3e737f27c23185e5ce00ac3eb077dc24ba Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:10:46 +0100 Subject: [PATCH 05/10] Update get_account_balance to use sum(weight) for SATS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace sum(position) with sum(weight) for efficient SATS aggregation from price notation. Also return fiat amount from sum(number). This simplifies the parsing logic and provides consistent SATS totals across all BQL-based balance methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 46 +++++++++++++++++++++++++--------------------- views_api.py | 3 ++- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/fava_client.py b/fava_client.py index 76aa022..e300867 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: diff --git a/views_api.py b/views_api.py index 86b96e6..7589d54 100644 --- a/views_api.py +++ b/views_api.py @@ -309,7 +309,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") } From addf4cd05f06bd57e01201670ccd300febed1af6 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:12:33 +0100 Subject: [PATCH 06/10] Optimize get_journal_entries with server-side date filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Fava's 'time' query parameter to filter entries on the server instead of fetching all entries and filtering in Python. This reduces: - Data transfer (only relevant entries are sent) - Memory usage (no need to hold all entries) - Processing time (no Python-side date parsing/filtering) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 73 ++++++++++++++++++-------------------------------- 1 file changed, 26 insertions(+), 47 deletions(-) diff --git a/fava_client.py b/fava_client.py index e300867..1b426b6 100644 --- a/fava_client.py +++ b/fava_client.py @@ -866,6 +866,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). @@ -890,59 +893,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 From 7dabe8700d3e5f6d7467a8a94a3836b3400b8afa Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:15:29 +0100 Subject: [PATCH 07/10] Add BQL-based report endpoints for expenses and contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints: - GET /api/v1/reports/expenses - Expense summary by account or month - GET /api/v1/reports/contributions - User contribution totals New FavaClient methods: - get_expense_summary_bql() - Aggregates expenses with date filtering - get_user_contributions_bql() - Aggregates user expense submissions Both use sum(weight) for efficient SATS aggregation from price notation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- fava_client.py | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 129 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) diff --git a/fava_client.py b/fava_client.py index 1b426b6..f7f9983 100644 --- a/fava_client.py +++ b/fava_client.py @@ -778,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, diff --git a/views_api.py b/views_api.py index 7589d54..4908cda 100644 --- a/views_api.py +++ b/views_api.py @@ -2215,6 +2215,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, From 1ae5c8c927600a07e87e9dc96b40b7c35f8ab9ec Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:21:44 +0100 Subject: [PATCH 08/10] Fix missing Optional import in views_api.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added typing.Optional import that was missing after adding the report endpoints with optional date parameters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- views_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/views_api.py b/views_api.py index 4908cda..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 From 7362d6292ec29c66add8e6bc974a2fe3010b174e Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:33:35 +0100 Subject: [PATCH 09/10] Fix settlement linking to original expense/receivable entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend now: 1. Fetches unsettled entries when opening settlement dialogs 2. Includes entry links (exp-xxx/rcv-xxx) in settlement payloads 3. Passes settled_entry_links to backend for proper linking This enables the settlement transaction to include links back to the original entries it is settling, making it possible to track which expenses/receivables have been paid. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- static/js/index.js | 67 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index a0b38ba..bd9e61a 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,19 @@ 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 receivable entries for this user + let unsettledEntries = [] + try { + const response = await LNbits.api.request( + 'GET', + `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, + this.g.user.wallets[0].adminkey + ) + unsettledEntries = response.data.unsettled_entries || [] + } catch (error) { + console.warn('Could not fetch unsettled entries:', error) + } + this.settleReceivableDialog = { show: true, user_id: userBalance.user_id, @@ -1168,7 +1181,9 @@ 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', + unsettledEntries: unsettledEntries, // Store for linking in settlement + entryLinks: unsettledEntries.map(e => e.link).filter(l => l) // Extract rcv-xxx links } }, async generateSettlementInvoice() { @@ -1304,6 +1319,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 +1349,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 @@ -1342,6 +1362,19 @@ window.app = Vue.createApp({ const maxAmountSats = Math.abs(userBalance.balance) const maxAmountFiat = Math.abs(fiatAmount) + // Fetch unsettled expense entries for this user + let unsettledEntries = [] + try { + const response = await LNbits.api.request( + 'GET', + `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, + this.g.user.wallets[0].adminkey + ) + unsettledEntries = response.data.unsettled_entries || [] + } catch (error) { + console.warn('Could not fetch unsettled entries:', error) + } + this.payUserDialog = { show: true, user_id: userBalance.user_id, @@ -1356,7 +1389,9 @@ window.app = Vue.createApp({ loading: false, paymentSuccess: false, exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate, - originalCurrency: fiatCurrency || 'BTC' + originalCurrency: fiatCurrency || 'BTC', + unsettledEntries: unsettledEntries, // Store for linking in settlement + entryLinks: unsettledEntries.map(e => e.link).filter(l => l) // Extract exp-xxx links } }, async sendLightningPayment() { @@ -1395,16 +1430,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 @@ -1461,6 +1503,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', From f8af54f90b5b7da6af0fafdf1cfc501e851d1776 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 15 Dec 2025 01:40:12 +0100 Subject: [PATCH 10/10] Include both expense and receivable links in net settlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both settlement dialogs now fetch BOTH expense and receivable entries to properly link all entries being netted in a settlement. This ensures that when a user has: - 2 expenses (80 EUR - castle owes user) - 1 receivable (1000 EUR - user owes castle) The net settlement (920 EUR) includes links to all three entries: ^exp-xxx ^exp-xxx ^rcv-xxx This allows proper tracking of which specific entries were settled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- static/js/index.js | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index bd9e61a..3fc736e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1151,15 +1151,26 @@ 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 receivable entries for this user - let unsettledEntries = [] + // Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement) + let allEntryLinks = [] try { - const response = await LNbits.api.request( + // 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 ) - unsettledEntries = response.data.unsettled_entries || [] + 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) } @@ -1182,8 +1193,7 @@ window.app = Vue.createApp({ pollIntervalId: null, exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate originalCurrency: fiatCurrency || 'BTC', - unsettledEntries: unsettledEntries, // Store for linking in settlement - entryLinks: unsettledEntries.map(e => e.link).filter(l => l) // Extract rcv-xxx links + entryLinks: allEntryLinks // Include BOTH rcv-xxx AND exp-xxx links for net settlement } }, async generateSettlementInvoice() { @@ -1362,15 +1372,26 @@ window.app = Vue.createApp({ const maxAmountSats = Math.abs(userBalance.balance) const maxAmountFiat = Math.abs(fiatAmount) - // Fetch unsettled expense entries for this user - let unsettledEntries = [] + // Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement) + let allEntryLinks = [] try { - const response = await LNbits.api.request( + // 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 ) - unsettledEntries = response.data.unsettled_entries || [] + 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) } @@ -1390,8 +1411,7 @@ window.app = Vue.createApp({ paymentSuccess: false, exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate, originalCurrency: fiatCurrency || 'BTC', - unsettledEntries: unsettledEntries, // Store for linking in settlement - entryLinks: unsettledEntries.map(e => e.link).filter(l => l) // Extract exp-xxx links + entryLinks: allEntryLinks // Include BOTH exp-xxx AND rcv-xxx links for net settlement } }, async sendLightningPayment() {