Creates accounts in Fava if they don't exist

This change ensures that user-specific accounts are automatically created
in the Fava/Beancount ledger when they are first requested. It checks for
the existence of the account via a Fava query and creates it via an Open
directive if it's missing.  This simplifies account management and
ensures that all necessary accounts are available for transactions.

This implementation adds a new `add_account` method to the `FavaClient`
class which makes use of the /add_entries endpoint to create an account
using an Open Directive.
This commit is contained in:
padreug 2025-11-10 15:56:22 +01:00
parent 51ae2e8e47
commit b6886793ee
2 changed files with 113 additions and 2 deletions

46
crud.py
View file

@ -2,6 +2,7 @@ import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
import httpx
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash 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. 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: Examples:
get_or_create_user_account("af983632", AccountType.ASSET, "Accounts Receivable") get_or_create_user_account("af983632", AccountType.ASSET, "Accounts Receivable")
"Assets:Receivable:User-af983632" "Assets:Receivable:User-af983632"
@ -104,11 +109,13 @@ async def get_or_create_user_account(
"Liabilities:Payable:User-af983632" "Liabilities:Payable:User-af983632"
""" """
from .account_utils import format_hierarchical_account_name from .account_utils import format_hierarchical_account_name
from .fava_client import get_fava_client
from loguru import logger
# Generate hierarchical account name # Generate hierarchical account name
account_name = format_hierarchical_account_name(account_type, base_name, user_id) 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( account = await db.fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
@ -119,7 +126,42 @@ async def get_or_create_user_account(
) )
if not 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( account = await create_account(
CreateAccount( CreateAccount(
name=account_name, name=account_name,

View file

@ -6,9 +6,11 @@ All accounting logic is delegated to Fava/Beancount.
Fava provides a REST API for: Fava provides a REST API for:
- Adding transactions (PUT /api/add_entries) - Adding transactions (PUT /api/add_entries)
- Adding accounts via Open directives (PUT /api/add_entries)
- Querying balances (GET /api/query) - Querying balances (GET /api/query)
- Balance sheets (GET /api/balance_sheet) - Balance sheets (GET /api/balance_sheet)
- Account reports (GET /api/account_report) - 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 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}") logger.error(f"Fava connection error: {e}")
raise 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) # Singleton instance (configured from settings)
_fava_client: Optional[FavaClient] = None _fava_client: Optional[FavaClient] = None