diff --git a/crud.py b/crud.py index b70be70..16680dc 100644 --- a/crud.py +++ b/crud.py @@ -2,6 +2,7 @@ import json from datetime import datetime from typing import Optional +import httpx from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash @@ -96,6 +97,10 @@ async def get_or_create_user_account( """ Get or create a user-specific account with hierarchical naming. + This function checks if the account exists in Fava/Beancount and creates it + if it doesn't exist. The account is also registered in Castle's database for + metadata tracking (permissions, descriptions, etc.). + Examples: get_or_create_user_account("af983632", AccountType.ASSET, "Accounts Receivable") → "Assets:Receivable:User-af983632" @@ -104,11 +109,13 @@ async def get_or_create_user_account( → "Liabilities:Payable:User-af983632" """ from .account_utils import format_hierarchical_account_name + from .fava_client import get_fava_client + from loguru import logger # Generate hierarchical account name account_name = format_hierarchical_account_name(account_type, base_name, user_id) - # Try to find existing account with this hierarchical name + # Try to find existing account with this hierarchical name in Castle DB account = await db.fetchone( """ SELECT * FROM accounts @@ -119,7 +126,42 @@ async def get_or_create_user_account( ) if not account: - # Create new account with hierarchical name + # Check if account exists in Fava/Beancount + fava = get_fava_client() + try: + # Query Fava for this account + query = f"SELECT account WHERE account = '{account_name}'" + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{fava.base_url}/query", + params={"query_string": query} + ) + response.raise_for_status() + result = response.json() + + # Check if account exists in Fava + fava_has_account = len(result.get("data", {}).get("rows", [])) > 0 + + if not fava_has_account: + # Create account in Fava/Beancount via Open directive + logger.info(f"Creating account in Fava: {account_name}") + await fava.add_account( + account_name=account_name, + currencies=["EUR", "SATS", "USD"], # Support common currencies + metadata={ + "user_id": user_id, + "description": f"User-specific {account_type.value} account" + } + ) + logger.info(f"Created account in Fava: {account_name}") + else: + logger.info(f"Account already exists in Fava: {account_name}") + + except Exception as e: + logger.warning(f"Could not check/create account in Fava: {e}") + # Continue anyway - account creation in Castle DB is still useful for metadata + + # Create account in Castle DB for metadata tracking account = await create_account( CreateAccount( name=account_name, diff --git a/fava_client.py b/fava_client.py index 5ae0da3..2c24cc9 100644 --- a/fava_client.py +++ b/fava_client.py @@ -6,9 +6,11 @@ All accounting logic is delegated to Fava/Beancount. Fava provides a REST API for: - Adding transactions (PUT /api/add_entries) +- Adding accounts via Open directives (PUT /api/add_entries) - Querying balances (GET /api/query) - Balance sheets (GET /api/balance_sheet) - Account reports (GET /api/account_report) +- Updating/deleting entries (PUT/DELETE /api/source_slice) See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py """ @@ -679,6 +681,73 @@ class FavaClient: logger.error(f"Fava connection error: {e}") raise + async def add_account( + self, + account_name: str, + currencies: list[str], + opening_date: Optional[date] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Add an account to the Beancount ledger via an Open directive. + + Args: + account_name: Full account name (e.g., "Assets:Receivable:User-abc123") + currencies: List of currencies for this account (e.g., ["EUR", "SATS"]) + opening_date: Date to open the account (defaults to today) + metadata: Optional metadata for the account + + Returns: + Response from Fava ({"data": "Stored 1 entries.", "mtime": "..."}) + + Example: + # Add a user's receivable account + result = await fava.add_account( + account_name="Assets:Receivable:User-abc123", + currencies=["EUR", "SATS", "USD"], + metadata={"user_id": "abc123", "description": "User receivables"} + ) + + # Add a user's payable account + result = await fava.add_account( + account_name="Liabilities:Payable:User-abc123", + currencies=["EUR", "SATS"] + ) + """ + from datetime import date as date_type + + if opening_date is None: + opening_date = date_type.today() + + # Format Open directive for Fava + open_directive = { + "t": "Open", + "date": opening_date.isoformat(), + "account": account_name, + "currencies": currencies, + "meta": metadata or {} + } + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.put( + f"{self.base_url}/add_entries", + json={"entries": [open_directive]}, + headers={"Content-Type": "application/json"} + ) + response.raise_for_status() + result = response.json() + + logger.info(f"Added account {account_name} to Fava with currencies {currencies}") + return result + + except httpx.HTTPStatusError as e: + logger.error(f"Fava HTTP error adding account: {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