Enables Fava integration for entry management

Adds functionality to interact with Fava for managing
Beancount entries, including fetching, updating, and
deleting entries directly from the Beancount ledger.

This allows for approving/rejecting pending entries
via the API by modifying the source file through Fava.

The changes include:
- Adds methods to the Fava client for fetching all journal
  entries, retrieving entry context (source and hash),
  updating the entry source, and deleting entries.
- Updates the pending entries API to use the Fava journal
  endpoint instead of querying transactions.
- Implements entry approval and rejection using the new
  Fava client methods to modify the underlying Beancount file.
This commit is contained in:
padreug 2025-11-10 00:22:01 +01:00
parent 57e6b3de1d
commit cfca10b782
2 changed files with 298 additions and 84 deletions

View file

@ -525,6 +525,149 @@ class FavaClient:
limit=limit
)
async def get_journal_entries(self) -> List[Dict[str, Any]]:
"""
Get all journal entries from Fava (with entry hashes).
Returns:
List of all entries (transactions, opens, closes, etc.) with entry_hash field.
Example:
entries = await fava.get_journal_entries()
# Each entry has: entry_hash, date, flag, narration, tags, links, etc.
"""
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()
return result.get("data", [])
except httpx.HTTPStatusError as e:
logger.error(f"Fava journal error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
"""
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:
context = await fava.get_entry_context("abc123")
source = context["slice"]
sha256sum = context["sha256sum"]
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/context",
params={"entry_hash": entry_hash}
)
response.raise_for_status()
result = response.json()
return result.get("data", {})
except httpx.HTTPStatusError as e:
logger.error(f"Fava context error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def update_entry_source(self, entry_hash: str, new_source: str, sha256sum: str) -> str:
"""
Update an entry's source text (e.g., change flag from ! to *).
Args:
entry_hash: Entry hash
new_source: Modified Beancount source text
sha256sum: Current sha256sum from get_entry_context() for concurrency control
Returns:
New sha256sum after update
Example:
# Get context
context = await fava.get_entry_context("abc123")
source = context["slice"]
sha256 = context["sha256sum"]
# Change flag
new_source = source.replace("2025-01-15 !", "2025-01-15 *")
# Update
new_sha256 = await fava.update_entry_source("abc123", new_source, sha256)
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/source_slice",
json={
"entry_hash": entry_hash,
"source": new_source,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e:
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
"""
Delete an entry from the Beancount file.
Args:
entry_hash: Entry hash
sha256sum: Current sha256sum for concurrency control
Returns:
Success message
Example:
context = await fava.get_entry_context("abc123")
await fava.delete_entry("abc123", context["sha256sum"])
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/source_slice",
json={
"entry_hash": entry_hash,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e:
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
# Singleton instance (configured from settings)
_fava_client: Optional[FavaClient] = None

View file

@ -373,75 +373,70 @@ async def api_get_pending_entries(
detail="Only super user can access this endpoint",
)
# Query Fava for all transactions including pending
# Query Fava for all journal entries (includes links, tags, full metadata)
fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
all_entries = await fava.get_journal_entries()
# Deduplicate and extract amounts
# BQL returns one row per posting, so we group by transaction
seen_transactions = {}
# Filter for pending transactions and extract info
pending_entries = []
for e in all_entries:
if e.get("flag") == "!":
# Create unique transaction key
date = e.get("date", "")
narration = e.get("narration", "")
txn_key = f"{date}:{narration}"
if e.get("t") == "Transaction" and e.get("flag") == "!":
# Extract entry ID from links field
entry_id = None
links = e.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str) and "castle-" in link:
parts = link.split("castle-")
if len(parts) > 1:
entry_id = parts[-1]
break
if isinstance(link, str):
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
if "castle-" in link_clean:
parts = link_clean.split("castle-")
if len(parts) > 1:
entry_id = parts[-1]
break
# Extract amount and fiat info from position field
# Extract amount from postings (sum of absolute values / 2)
amount_sats = 0
fiat_amount = None
fiat_currency = None
position = e.get("position")
if isinstance(position, dict):
# Extract sats amount
units = position.get("units", {})
if isinstance(units, dict) and "number" in units:
amount_sats = abs(int(units.get("number", 0)))
postings = e.get("postings", [])
if postings:
# Get amount from first posting
first_posting = postings[0]
if isinstance(first_posting, dict):
amount_field = first_posting.get("amount")
if isinstance(amount_field, dict):
# Parse amount like {"number": "42185", "currency": "SATS"}
amount_sats = abs(int(float(amount_field.get("number", 0))))
# Extract fiat amount from cost basis
cost = position.get("cost", {})
if isinstance(cost, dict):
if "number" in cost:
fiat_amount = cost.get("number")
if "currency" in cost:
# Get fiat from cost
cost = first_posting.get("cost")
if isinstance(cost, dict):
fiat_amount = float(cost.get("number", 0))
fiat_currency = cost.get("currency")
# Only keep first occurrence (or update with positive amount)
if txn_key not in seen_transactions or amount_sats > 0:
entry_data = {
"id": entry_id or "unknown",
"date": date,
"entry_date": date, # Add for frontend compatibility
"flag": e.get("flag"),
"description": narration,
"payee": e.get("payee"),
"tags": e.get("tags", []),
"links": links,
"amount": amount_sats,
"account": e.get("account", ""),
}
entry_data = {
"id": entry_id or "unknown",
"date": e.get("date", ""),
"entry_date": e.get("date", ""),
"flag": e.get("flag"),
"description": e.get("narration", ""),
"payee": e.get("payee"),
"tags": e.get("tags", []),
"links": links,
"amount": amount_sats,
}
# Add fiat info if available
if fiat_amount and fiat_currency:
entry_data["fiat_amount"] = fiat_amount
entry_data["fiat_currency"] = fiat_currency
# Add fiat info if available
if fiat_amount and fiat_currency:
entry_data["fiat_amount"] = fiat_amount
entry_data["fiat_currency"] = fiat_currency
seen_transactions[txn_key] = entry_data
pending_entries.append(entry_data)
return list(seen_transactions.values())
return pending_entries
@castle_api_router.get("/api/v1/entries/{entry_id}")
@ -1935,12 +1930,12 @@ async def api_approve_expense_entry(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Approve a pending expense entry (admin only).
Approve a pending expense entry by changing flag from '!' to '*' (admin only).
With Fava integration, entries must be approved through Fava UI or API.
This endpoint provides instructions on how to approve entries.
This updates the transaction in the Beancount file via Fava API.
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
@ -1948,31 +1943,84 @@ async def api_approve_expense_entry(
detail="Only super user can approve expenses",
)
# TODO: Implement Fava entry update via PUT /api/source_slice
# This requires:
# 1. Query Fava for entry by link (^castle-{entry_id} or similar)
# 2. Get the entry's source text
# 3. Change flag from ! to *
# 4. Submit updated source back to Fava
fava = get_fava_client()
# For now, return instructions
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail=(
f"Entry approval via API not yet implemented with Fava integration. "
f"To approve entry {entry_id}, open Fava and edit the transaction to change the flag from '!' to '*'. "
f"Fava URL: http://localhost:3333/castle-ledger/"
# 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None
for entry in all_entries:
# Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry_hash = entry.get("entry_hash")
target_entry = entry
break
if target_entry_hash:
break
if not target_entry_hash:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Pending entry {entry_id} not found in Beancount ledger"
)
)
# 3. Get the entry context (source text + sha256sum)
context = await fava.get_entry_context(target_entry_hash)
source = context.get("slice", "")
sha256sum = context.get("sha256sum", "")
if not source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not retrieve entry source from Fava"
)
# 4. Change flag from ! to *
# Replace the first occurrence of the date + ! pattern
import re
date_str = target_entry.get("date", "")
old_pattern = f"{date_str} !"
new_pattern = f"{date_str} *"
if old_pattern not in source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not find pending flag pattern '{old_pattern}' in entry source"
)
new_source = source.replace(old_pattern, new_pattern, 1)
# 5. Update the entry via Fava API
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
return {
"message": f"Entry {entry_id} approved successfully",
"entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": date_str,
"description": target_entry.get("narration", "")
}
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
async def api_reject_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""Reject a pending expense entry (admin only)"""
) -> dict:
"""
Reject a pending expense entry by deleting it from the Beancount file (admin only).
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
@ -1980,27 +2028,50 @@ async def api_reject_expense_entry(
detail="Only super user can reject expenses",
)
# Get the entry
entry = await get_journal_entry(entry_id)
if not entry:
fava = get_fava_client()
# 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None
for entry in all_entries:
# Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry_hash = entry.get("entry_hash")
target_entry = entry
break
if target_entry_hash:
break
if not target_entry_hash:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Journal entry not found",
detail=f"Pending entry {entry_id} not found in Beancount ledger"
)
if entry.flag != JournalEntryFlag.PENDING:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Entry is not pending (current status: {entry.flag.value})",
)
# 3. Get the entry context for sha256sum
context = await fava.get_entry_context(target_entry_hash)
sha256sum = context.get("sha256sum", "")
# Since entries are now in Fava/Beancount, voiding requires editing the Beancount file
# Beancount doesn't have a "void" flag - recommend using ! flag + #voided tag
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="To reject/void entry, open Fava and either delete the transaction or add the #voided tag. "
"Beancount only supports * (cleared) and ! (pending) flags."
)
# 4. Delete the entry
result = await fava.delete_entry(target_entry_hash, sha256sum)
return {
"message": f"Entry {entry_id} rejected and deleted successfully",
"entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": target_entry.get("date", ""),
"description": target_entry.get("narration", "")
}
# ===== BALANCE ASSERTION ENDPOINTS =====