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:
parent
57e6b3de1d
commit
cfca10b782
2 changed files with 298 additions and 84 deletions
143
fava_client.py
143
fava_client.py
|
|
@ -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
|
||||
|
|
|
|||
203
views_api.py
203
views_api.py
|
|
@ -373,65 +373,60 @@ 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 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", {})
|
||||
# Get fiat from cost
|
||||
cost = first_posting.get("cost")
|
||||
if isinstance(cost, dict):
|
||||
if "number" in cost:
|
||||
fiat_amount = cost.get("number")
|
||||
if "currency" in cost:
|
||||
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
|
||||
"date": e.get("date", ""),
|
||||
"entry_date": e.get("date", ""),
|
||||
"flag": e.get("flag"),
|
||||
"description": narration,
|
||||
"description": e.get("narration", ""),
|
||||
"payee": e.get("payee"),
|
||||
"tags": e.get("tags", []),
|
||||
"links": links,
|
||||
"amount": amount_sats,
|
||||
"account": e.get("account", ""),
|
||||
}
|
||||
|
||||
# Add fiat info if available
|
||||
|
|
@ -439,9 +434,9 @@ async def api_get_pending_entries(
|
|||
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
|
||||
# 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_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/"
|
||||
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 =====
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue