Compare commits

..

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

3 changed files with 140 additions and 724 deletions

View file

@ -111,16 +111,13 @@ class FavaClient:
"""
Get balance for a specific account (excluding pending transactions).
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,

View file

@ -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',

View file

@ -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)