Compare commits

...

10 commits

Author SHA1 Message Date
f8af54f90b Include both expense and receivable links in net settlements
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 <noreply@anthropic.com>
2025-12-15 01:40:12 +01:00
7362d6292e Fix settlement linking to original expense/receivable entries
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 <noreply@anthropic.com>
2025-12-15 01:33:35 +01:00
1ae5c8c927 Fix missing Optional import in views_api.py
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 <noreply@anthropic.com>
2025-12-15 01:21:44 +01:00
7dabe8700d Add BQL-based report endpoints for expenses and contributions
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 <noreply@anthropic.com>
2025-12-15 01:15:29 +01:00
addf4cd05f Optimize get_journal_entries with server-side date filtering
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 <noreply@anthropic.com>
2025-12-15 01:12:33 +01:00
49d18c3e73 Update get_account_balance to use sum(weight) for SATS
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 <noreply@anthropic.com>
2025-12-15 01:10:46 +01:00
048d19f90b Add BQL-optimized get_unsettled_entries_bql method
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 <noreply@anthropic.com>
2025-12-15 01:08:38 +01:00
55df2b36e0 Fix Pay User dialog showing negative values
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 <noreply@anthropic.com>
2025-12-15 00:54:51 +01:00
116355b502 Fix get_entry_context to use /source_slice endpoint
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 <noreply@anthropic.com>
2025-12-15 00:35:22 +01:00
913e4705b1 Fix amount parsing to handle both @ and @@ SATS notation
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 <noreply@anthropic.com>
2025-12-15 00:19:49 +01:00
3 changed files with 724 additions and 140 deletions

View file

@ -111,13 +111,16 @@ class FavaClient:
"""
Get balance for a specific account (excluding pending transactions).
Uses sum(weight) for efficient SATS aggregation from price notation.
Args:
account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
Returns:
Dict with:
- sats: int (balance in satoshis)
- positions: dict (currency amount with cost basis)
- sats: int (balance in satoshis from weight column)
- fiat: Decimal (balance in fiat currency from number column)
- fiat_currency: str (currency code, defaults to EUR)
Note:
Excludes pending transactions (flag='!') from balance calculation.
@ -125,12 +128,13 @@ class FavaClient:
Example:
balance = await fava_client.get_account_balance("Assets:Receivable:User-abc")
# Returns: {
# "sats": 200000,
# "positions": {"SATS": {"{100.00 EUR}": 200000}}
# }
# Returns: {"sats": 200000, "fiat": Decimal("150.00"), "fiat_currency": "EUR"}
"""
query = f"SELECT sum(position) WHERE account = '{account_name}' AND flag != '!'"
from decimal import Decimal
# Use sum(weight) for SATS and sum(number) for fiat
# Note: BQL doesn't support != operator, so use flag = '*' to exclude pending
query = f"SELECT sum(number), sum(weight) WHERE account = '{account_name}' AND flag = '*'"
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
@ -141,26 +145,26 @@ class FavaClient:
response.raise_for_status()
data = response.json()
if not data['data']['rows']:
return {"sats": 0, "positions": {}}
if not data['data']['rows'] or not data['data']['rows'][0]:
return {"sats": 0, "fiat": Decimal(0), "fiat_currency": "EUR"}
# Fava returns: [[account, {"SATS": {cost: amount}}]]
positions = data['data']['rows'][0][1] if data['data']['rows'] else {}
row = data['data']['rows'][0]
fiat_sum = row[0] if len(row) > 0 else 0
weight_sum = row[1] if len(row) > 1 else {}
# Sum up all SATS positions
# Parse fiat amount
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
# Parse SATS from weight column
total_sats = 0
if isinstance(positions, dict) and "SATS" in positions:
sats_positions = positions["SATS"]
if isinstance(sats_positions, dict):
# Sum all amounts (with different cost bases)
total_sats = sum(int(amount) for amount in sats_positions.values())
elif isinstance(sats_positions, (int, float)):
# Simple number (no cost basis)
total_sats = int(sats_positions)
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_value = weight_sum["SATS"]
total_sats = int(Decimal(str(sats_value)))
return {
"sats": total_sats,
"positions": positions
"fiat": fiat_amount,
"fiat_currency": "EUR" # Default, could be extended to detect currency
}
except httpx.HTTPStatusError as e:
@ -224,19 +228,53 @@ class FavaClient:
continue
import re
# Try to extract EUR/USD amount first (new format)
# 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
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'):
# 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
# Also track SATS equivalent from metadata if available
# Also track SATS equivalent from metadata if available (legacy)
posting_meta = posting.get("meta", {})
sats_equiv = posting_meta.get("sats-equivalent")
if sats_equiv:
@ -347,19 +385,45 @@ class FavaClient:
continue
import re
# Try to extract EUR/USD amount first (new format)
# 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
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'):
# 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
# Also track SATS equivalent from metadata if available
# Also track SATS equivalent from metadata if available (legacy)
posting_meta = posting.get("meta", {})
sats_equiv = posting_meta.get("sats-equivalent")
if sats_equiv:
@ -714,6 +778,168 @@ class FavaClient:
return list(user_data.values())
async def get_expense_summary_bql(
self,
start_date: str = None,
end_date: str = None,
group_by: str = "account"
) -> List[Dict[str, Any]]:
"""
Get expense summary using BQL, grouped by account or date.
Uses sum(weight) for efficient SATS aggregation from price notation.
Args:
start_date: ISO format date string (YYYY-MM-DD), optional
end_date: ISO format date string (YYYY-MM-DD), optional
group_by: "account" (default) or "month" for grouping
Returns:
List of expense summaries:
[
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
{"account": "Expenses:Supplies:Kitchen", "fiat": 200.00, "sats": 220000},
...
]
Or if group_by="month":
[
{"month": "2025-12", "fiat": 700.00, "sats": 770000},
...
]
"""
from decimal import Decimal
# Build date filter
date_filter = ""
if start_date:
date_filter += f" AND date >= {start_date}"
if end_date:
date_filter += f" AND date <= {end_date}"
if group_by == "month":
query = f"""
SELECT year, month, sum(number), sum(weight)
WHERE account ~ 'Expenses:'
AND flag = '*'
{date_filter}
GROUP BY year, month
ORDER BY year DESC, month DESC
"""
else:
query = f"""
SELECT account, sum(number), sum(weight)
WHERE account ~ 'Expenses:'
AND flag = '*'
{date_filter}
GROUP BY account
ORDER BY sum(weight)
"""
try:
result = await self.query_bql(query)
summaries = []
for row in result["rows"]:
if group_by == "month":
year, month, fiat_sum, weight_sum = row
# Parse SATS from weight
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
summaries.append({
"month": f"{year}-{month:02d}",
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
"fiat_currency": "EUR",
"sats": sats_amount
})
else:
account, fiat_sum, weight_sum = row
# Parse SATS from weight
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
summaries.append({
"account": account,
"fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
"fiat_currency": "EUR",
"sats": sats_amount
})
logger.info(f"BQL: Expense summary returned {len(summaries)} items")
return summaries
except Exception as e:
logger.error(f"Error getting expense summary via BQL: {e}")
raise
async def get_user_contributions_bql(self) -> List[Dict[str, Any]]:
"""
Get total expense contributions per user using BQL.
Uses sum(weight) to aggregate all expenses each user has submitted
that created liabilities (castle owes user).
Returns:
List of user contribution summaries:
[
{
"user_id": "cfe378b3",
"total_fiat": 1500.00,
"total_sats": 1650000,
"entry_count": 25
},
...
]
"""
from decimal import Decimal
# Query all expense entries that created payables, grouped by user
query = """
SELECT account, sum(number), sum(weight), count(number)
WHERE account ~ 'Liabilities:Payable:User-'
AND 'expense-entry' IN tags
AND flag = '*'
GROUP BY account
ORDER BY sum(weight)
"""
try:
result = await self.query_bql(query)
contributions = []
for row in result["rows"]:
account, fiat_sum, weight_sum, count = row
# Extract user_id from account name
if ":User-" not in account:
continue
user_id = account.split(":User-")[1][:8]
# Parse SATS from weight (negative for liabilities)
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_amount = abs(int(Decimal(str(weight_sum["SATS"]))))
contributions.append({
"user_id": user_id,
"total_fiat": abs(float(fiat_sum)) if fiat_sum else 0.0,
"fiat_currency": "EUR",
"total_sats": sats_amount,
"entry_count": int(count) if count else 0
})
# Sort by total_sats descending (highest contributors first)
contributions.sort(key=lambda x: x["total_sats"], reverse=True)
logger.info(f"BQL: Found contributions from {len(contributions)} users")
return contributions
except Exception as e:
logger.error(f"Error getting user contributions via BQL: {e}")
raise
async def get_account_transactions(
self,
account_name: str,
@ -802,6 +1028,9 @@ class FavaClient:
"""
Get journal entries from Fava (with entry hashes), optionally filtered by date.
Uses Fava's server-side 'time' parameter for efficient date filtering,
avoiding the need to fetch all entries and filter in Python.
Args:
days: If provided, only return entries from the last N days.
If None, returns all entries (default behavior).
@ -826,59 +1055,35 @@ class FavaClient:
# Get entries in custom date range
custom = await fava.get_journal_entries(start_date="2024-01-01", end_date="2024-01-31")
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
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")
# Filter by date range or days
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:
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
# 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()
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
today = datetime.now().date()
params["time"] = f"{cutoff_date.isoformat()} - {today.isoformat()}"
logger.info(f"Querying journal for last {days} days (from {cutoff_date})")
# 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")
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/journal", params=params)
response.raise_for_status()
result = response.json()
entries = result.get("data", [])
if params:
logger.info(f"Fava /journal returned {len(entries)} entries (filtered)")
else:
logger.info(f"Fava /journal returned {len(entries)} entries (all)")
return entries
@ -891,18 +1096,17 @@ class FavaClient:
async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
"""
Get entry context including source text and sha256sum.
Get entry source text and sha256sum for editing.
Uses /source_slice endpoint which returns the editable source.
Args:
entry_hash: Entry hash from get_journal_entries()
Returns:
{
"entry": {...}, # Serialized entry
"slice": "2025-01-15 ! \"Description\"...", # Beancount source text
"sha256sum": "abc123...", # For concurrency control
"balances_before": {...},
"balances_after": {...}
}
Example:
@ -913,7 +1117,7 @@ class FavaClient:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/context",
f"{self.base_url}/source_slice",
params={"entry_hash": entry_hash}
)
response.raise_for_status()
@ -1132,6 +1336,151 @@ class FavaClient:
logger.error(f"Fava connection error: {e}")
raise
async def get_unsettled_entries_bql(
self,
user_id: str,
entry_type: str = "expense"
) -> List[Dict[str, Any]]:
"""
Get unsettled expense or receivable entries for a user using BQL.
Uses BQL queries to efficiently find entries with exp-{id} or rcv-{id}
links that don't have a corresponding settlement entry.
This is significantly more efficient than the legacy method as it:
- Queries only relevant entries (not ALL journal entries)
- Uses weight column for SATS amounts (no string parsing)
- Filters by tags and account patterns in the database
Args:
user_id: User ID (first 8 characters used for account matching)
entry_type: "expense" (payables - castle owes user) or
"receivable" (user owes castle)
Returns:
List of unsettled entries with:
- link: The entry's unique link (exp-xxx or rcv-xxx)
- date: Entry date
- narration: Description
- fiat_amount: Amount in fiat currency (absolute value)
- fiat_currency: Currency code
- sats_amount: Amount in SATS (absolute value, from weight)
- entry_hash: For potential updates
- flag: Transaction flag
"""
from decimal import Decimal
user_short = user_id[:8]
link_prefix = "exp-" if entry_type == "expense" else "rcv-"
entry_tag = "expense-entry" if entry_type == "expense" else "receivable-entry"
# Determine account pattern based on entry type
if entry_type == "expense":
account_pattern = f"Liabilities:Payable:User-{user_short}"
else:
account_pattern = f"Assets:Receivable:User-{user_short}"
try:
# Query 1: Get all original expense/receivable entries for this user
# These are entries with the expense-entry or receivable-entry tag
original_query = f"""
SELECT date, narration, account, number, weight, links,
any_meta('entry-id') as entry_id
WHERE account ~ '{account_pattern}'
AND '{entry_tag}' IN tags
AND flag = '*'
ORDER BY date
"""
original_result = await self.query_bql(original_query)
# Query 2: Get all settlement entries for this user
# These are entries with the settlement tag
settlement_query = f"""
SELECT links
WHERE account ~ '{account_pattern}'
AND 'settlement' IN tags
AND flag = '*'
"""
settlement_result = await self.query_bql(settlement_query)
# Build set of settled links from settlement entries
settled_links: set = set()
for row in settlement_result["rows"]:
links = row[0] if row else []
if isinstance(links, list):
for link in links:
if link.startswith(link_prefix):
settled_links.add(link)
# Process original entries and find unsettled ones
entries_by_link: Dict[str, Dict[str, Any]] = {}
for row in original_result["rows"]:
date_val, narration, account, number, weight, links, entry_id = row
# Skip if no links
if not links or not isinstance(links, list):
continue
# Find the exp-/rcv- link
entry_link = None
for link in links:
if link.startswith(link_prefix):
entry_link = link
break
if not entry_link:
continue
# Skip if already settled
if entry_link in settled_links:
continue
# Skip if we already have this entry (BQL returns one row per posting)
if entry_link in entries_by_link:
continue
# Parse amounts
fiat_amount = abs(float(number)) if number else 0.0
fiat_currency = "EUR" # Default, could be extracted from posting
# Parse SATS from weight column
sats_amount = 0
if isinstance(weight, dict) and "SATS" in weight:
sats_value = weight["SATS"]
sats_amount = abs(int(Decimal(str(sats_value))))
# Format date as string
date_str = str(date_val) if date_val else ""
entries_by_link[entry_link] = {
"link": entry_link,
"date": date_str,
"narration": narration or "",
"fiat_amount": fiat_amount,
"fiat_currency": fiat_currency,
"sats_amount": sats_amount,
"entry_hash": "", # Not available from BQL, use entry_id instead
"entry_id": entry_id or "",
"flag": "*"
}
# Convert to list and sort by date
unsettled = list(entries_by_link.values())
unsettled.sort(key=lambda x: x.get("date", ""))
logger.info(
f"BQL: Found {len(unsettled)} unsettled {entry_type} entries for user {user_short} "
f"(settled: {len(settled_links)})"
)
return unsettled
except Exception as e:
logger.error(f"Error getting unsettled entries via BQL: {e}")
raise
async def get_unsettled_entries(
self,

View file

@ -1137,7 +1137,7 @@ window.app = Vue.createApp({
this.receivableDialog.reference = ''
this.receivableDialog.currency = null
},
showSettleReceivableDialog(userBalance) {
async showSettleReceivableDialog(userBalance) {
// Only show for users who owe castle (positive balance = receivable)
if (userBalance.balance <= 0) return
@ -1151,6 +1151,30 @@ window.app = Vue.createApp({
const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR')
const fiatAmount = fiatCurrency ? Math.abs(fiatBalances[fiatCurrency]) : 0
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
let allEntryLinks = []
try {
// Fetch receivable entries (user owes castle)
const receivableResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey
)
const receivableEntries = receivableResponse.data.unsettled_entries || []
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
// Also fetch expense entries (castle owes user) - these are netted in the settlement
const expenseResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey
)
const expenseEntries = expenseResponse.data.unsettled_entries || []
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
} catch (error) {
console.warn('Could not fetch unsettled entries:', error)
}
this.settleReceivableDialog = {
show: true,
user_id: userBalance.user_id,
@ -1168,7 +1192,8 @@ window.app = Vue.createApp({
checkWalletKey: null,
pollIntervalId: null,
exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate
originalCurrency: fiatCurrency || 'BTC'
originalCurrency: fiatCurrency || 'BTC',
entryLinks: allEntryLinks // Include BOTH rcv-xxx AND exp-xxx links for net settlement
}
},
async generateSettlementInvoice() {
@ -1304,6 +1329,11 @@ window.app = Vue.createApp({
payload.amount_sats = this.settleReceivableDialog.maxAmount
}
// Include links to entries being settled
if (this.settleReceivableDialog.entryLinks && this.settleReceivableDialog.entryLinks.length > 0) {
payload.settled_entry_links = this.settleReceivableDialog.entryLinks
}
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/receivables/settle',
@ -1329,7 +1359,7 @@ window.app = Vue.createApp({
this.settleReceivableDialog.loading = false
}
},
showPayUserDialog(userBalance) {
async showPayUserDialog(userBalance) {
// Only show for users castle owes (negative balance = payable)
if (userBalance.balance >= 0) return
@ -1338,21 +1368,50 @@ window.app = Vue.createApp({
const fiatCurrency = Object.keys(fiatBalances)[0] || null
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
// Use absolute values since balance is negative (liability = castle owes user)
const maxAmountSats = Math.abs(userBalance.balance)
const maxAmountFiat = Math.abs(fiatAmount)
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
let allEntryLinks = []
try {
// Fetch expense entries (castle owes user)
const expenseResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey
)
const expenseEntries = expenseResponse.data.unsettled_entries || []
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
// Also fetch receivable entries (user owes castle) - these are netted in the settlement
const receivableResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey
)
const receivableEntries = receivableResponse.data.unsettled_entries || []
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
} catch (error) {
console.warn('Could not fetch unsettled entries:', error)
}
this.payUserDialog = {
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: userBalance.balance, // Positive sats amount castle owes
maxAmountFiat: fiatAmount, // EUR or other fiat amount
maxAmount: maxAmountSats, // Positive sats amount castle owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
fiatCurrency: fiatCurrency,
amount: fiatCurrency ? fiatAmount : userBalance.balance, // Default to fiat if available
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
payment_method: 'lightning', // Default to lightning for paying
description: '',
reference: '',
loading: false,
paymentSuccess: false,
exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : this.currentExchangeRate,
originalCurrency: fiatCurrency || 'BTC'
exchangeRate: maxAmountFiat > 0 ? maxAmountSats / maxAmountFiat : this.currentExchangeRate,
originalCurrency: fiatCurrency || 'BTC',
entryLinks: allEntryLinks // Include BOTH exp-xxx AND rcv-xxx links for net settlement
}
},
async sendLightningPayment() {
@ -1391,16 +1450,23 @@ window.app = Vue.createApp({
)
// Record the payment in Castle accounting
await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
{
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
)
this.payUserDialog.paymentSuccess = true
@ -1457,6 +1523,11 @@ window.app = Vue.createApp({
payload.amount_sats = this.payUserDialog.maxAmount
}
// Include links to entries being settled
if (this.payUserDialog.entryLinks && this.payUserDialog.entryLinks.length > 0) {
payload.settled_entry_links = this.payUserDialog.entryLinks
}
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',

View file

@ -1,6 +1,7 @@
from datetime import datetime
from decimal import Decimal
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
@ -309,7 +310,8 @@ async def api_get_account_balance(account_id: str) -> dict:
return {
"account_id": account_id,
"balance": balance_data["sats"], # Balance in satoshis
"positions": balance_data["positions"] # Full Beancount positions with cost basis
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
"fiat_currency": balance_data.get("fiat_currency", "EUR")
}
@ -508,21 +510,38 @@ async def api_get_user_entries(
if isinstance(first_posting, dict):
amount_str = first_posting.get("amount", "")
# Parse amount string: can be EUR/USD directly (new format) or "SATS {EUR}" (old format)
# Parse amount string: price notation, simple fiat, or legacy SATS format
if isinstance(amount_str, str) and amount_str:
import re
# Try EUR/USD format first (new format: "37.22 EUR")
# 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'):
# Direct fiat amount (new approach)
fiat_amount = abs(float(fiat_match.group(1)))
fiat_currency = fiat_match.group(2)
# Get SATS from metadata
# 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,13 +800,29 @@ 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"
# 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)
# Extract sats equivalent from metadata
# Extract sats equivalent from metadata (legacy)
posting_meta = first_posting.get("meta", {})
sats_equiv = posting_meta.get("sats-equivalent")
if sats_equiv:
@ -2181,6 +2216,135 @@ async def api_get_castle_users(
return users
@castle_api_router.get("/api/v1/reports/expenses")
async def api_expense_report(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
group_by: str = "account",
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Get expense summary report using BQL.
Args:
start_date: Filter from this date (YYYY-MM-DD), optional
end_date: Filter to this date (YYYY-MM-DD), optional
group_by: "account" (by expense category) or "month" (by month)
Returns:
{
"summary": [
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
...
],
"total_fiat": 1500.00,
"total_sats": 1650000,
"fiat_currency": "EUR",
"group_by": "account",
"start_date": "2025-01-01",
"end_date": "2025-12-31"
}
Admin only.
"""
from .fava_client import get_fava_client
if group_by not in ["account", "month"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="group_by must be 'account' or 'month'"
)
fava = get_fava_client()
summaries = await fava.get_expense_summary_bql(
start_date=start_date,
end_date=end_date,
group_by=group_by
)
# Calculate totals
total_fiat = sum(s.get("fiat", 0) for s in summaries)
total_sats = sum(s.get("sats", 0) for s in summaries)
return {
"summary": summaries,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"group_by": group_by,
"start_date": start_date,
"end_date": end_date,
"count": len(summaries)
}
@castle_api_router.get("/api/v1/reports/contributions")
async def api_contributions_report(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Get user contribution report using BQL.
Shows total expenses submitted by each user (creating payables).
Returns:
{
"contributions": [
{
"user_id": "cfe378b3",
"username": "alice",
"total_fiat": 1500.00,
"total_sats": 1650000,
"entry_count": 25
},
...
],
"total_fiat": 5000.00,
"total_sats": 5500000,
"fiat_currency": "EUR",
"user_count": 5
}
Admin only.
"""
from lnbits.core.crud.users import get_user
from .fava_client import get_fava_client
fava = get_fava_client()
contributions = await fava.get_user_contributions_bql()
# Enrich with usernames
for contrib in contributions:
user_id = contrib["user_id"]
# Try to find full user_id from wallet settings
settings = await get_all_user_wallet_settings()
full_user_id = None
for s in settings:
if s.id.startswith(user_id):
full_user_id = s.id
break
if full_user_id:
user = await get_user(full_user_id)
contrib["username"] = user.username if user and user.username else None
contrib["full_user_id"] = full_user_id
else:
contrib["username"] = None
contrib["full_user_id"] = None
# Calculate totals
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
total_sats = sum(c.get("total_sats", 0) for c in contributions)
return {
"contributions": contributions,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"user_count": len(contributions)
}
@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries")
async def api_get_unsettled_entries(
user_id: str,
@ -2231,7 +2395,7 @@ async def api_get_unsettled_entries(
)
fava = get_fava_client()
unsettled = await fava.get_unsettled_entries(user_id, entry_type)
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
# Calculate totals
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)