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 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,

View file

@ -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