diff --git a/views_api.py b/views_api.py index 77eadaf..dc4d1d1 100644 --- a/views_api.py +++ b/views_api.py @@ -2636,6 +2636,7 @@ async def api_approve_expense_entry( This updates the transaction in the Beancount file via Fava API. """ + import httpx from .fava_client import get_fava_client fava = get_fava_client() @@ -2644,7 +2645,6 @@ async def api_approve_expense_entry( 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: @@ -2656,51 +2656,86 @@ async def api_approve_expense_entry( 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: + if target_entry: break - if not target_entry_hash: + if not target_entry: 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 + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") date_str = target_entry.get("date", "") - old_pattern = f"{date_str} !" - new_pattern = f"{date_str} *" - if old_pattern not in source: + if not filename or not lineno: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Could not find pending flag pattern '{old_pattern}' in entry source" + detail="Entry metadata missing filename or lineno" ) - new_source = source.replace(old_pattern, new_pattern, 1) + # 3. Get the source file from Fava + async with httpx.AsyncClient(timeout=fava.timeout) as client: + response = await client.get( + f"{fava.base_url}/source", + params={"filename": filename} + ) + response.raise_for_status() + source_data = response.json()["data"] - # 5. Update the entry via Fava API - await fava.update_entry_source(target_entry_hash, new_source, sha256sum) + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 4. Find and modify the entry at the specified line + # Line numbers are 1-indexed, list is 0-indexed + entry_line_idx = lineno - 1 + + if entry_line_idx >= len(lines): + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} not found in source file" + ) + + entry_line = lines[entry_line_idx] + + # Check if the line contains the pending flag pattern + old_pattern = f"{date_str} !" + if old_pattern not in entry_line: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}" + ) + + # Replace the flag + new_pattern = f"{date_str} *" + new_line = entry_line.replace(old_pattern, new_pattern, 1) + lines[entry_line_idx] = new_line + + # 5. Write back the modified source + new_source = '\n'.join(lines) + + update_response = await client.put( + f"{fava.base_url}/source", + json={ + "file_path": filename, + "source": new_source, + "sha256sum": sha256sum + }, + headers={"Content-Type": "application/json"} + ) + update_response.raise_for_status() + + logger.info(f"Entry {entry_id} approved (flag changed to *)") 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", "") } @@ -2717,6 +2752,7 @@ async def api_reject_expense_entry( Adds #voided tag for audit trail while keeping the '!' flag. Voided transactions are excluded from balances but preserved in the ledger. """ + import httpx from .fava_client import get_fava_client fava = get_fava_client() @@ -2725,7 +2761,6 @@ async def api_reject_expense_entry( 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: @@ -2737,58 +2772,77 @@ async def api_reject_expense_entry( 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: + if target_entry: break - if not target_entry_hash: + if not target_entry: 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. Add #voided tag (keep ! flag as per convention) + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") date_str = target_entry.get("date", "") - # Add #voided tag if not already present - if "#voided" not in source: - # Find the transaction line and add #voided to the tags - # Pattern: date ! "narration" #existing-tags - lines = source.split('\n') - for i, line in enumerate(lines): - if date_str in line and '"' in line and '!' in line: - # Add #voided tag to the transaction line - if '#' in line: - # Already has tags, append voided - lines[i] = line.rstrip() + ' #voided' - else: - # No tags yet, add after narration - lines[i] = line.rstrip() + ' #voided' - break - new_source = '\n'.join(lines) - else: - new_source = source + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) - # 5. Update the entry via Fava API - await fava.update_entry_source(target_entry_hash, new_source, sha256sum) + # 3. Get the source file from Fava + async with httpx.AsyncClient(timeout=fava.timeout) as client: + response = await client.get( + f"{fava.base_url}/source", + params={"filename": filename} + ) + response.raise_for_status() + source_data = response.json()["data"] + + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 4. Find and modify the entry at the specified line - add #voided tag + entry_line_idx = lineno - 1 + + if entry_line_idx >= len(lines): + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} not found in source file" + ) + + entry_line = lines[entry_line_idx] + + # Add #voided tag if not already present + if "#voided" not in entry_line: + # Add #voided tag to the transaction line + new_line = entry_line.rstrip() + ' #voided' + lines[entry_line_idx] = new_line + + # 5. Write back the modified source + new_source = '\n'.join(lines) + + update_response = await client.put( + f"{fava.base_url}/source", + json={ + "file_path": filename, + "source": new_source, + "sha256sum": sha256sum + }, + headers={"Content-Type": "application/json"} + ) + update_response.raise_for_status() + logger.info(f"Entry {entry_id} rejected (added #voided tag)") return { "message": f"Entry {entry_id} rejected (marked as voided)", "entry_id": entry_id, - "entry_hash": target_entry_hash, "date": date_str, "description": target_entry.get("narration", "") }