diff --git a/fava_client.py b/fava_client.py index 0757121..3c9861d 100644 --- a/fava_client.py +++ b/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 diff --git a/views_api.py b/views_api.py index fbfc1a5..bf72ee8 100644 --- a/views_api.py +++ b/views_api.py @@ -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 =====