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:
parent
51ae2e8e47
commit
b6886793ee
2 changed files with 113 additions and 2 deletions
46
crud.py
46
crud.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue