Adds Fava integration for journal entries

Integrates Fava/Beancount for managing journal entries.

This change introduces functions to format entries into Beancount
format and submit them to a Fava instance.

It replaces the previous direct database entry creation with Fava
submission for expense, receivable, and revenue entries. The existing
create_journal_entry function is also updated to submit generic
journal entries to Fava.
This commit is contained in:
padreug 2025-11-09 22:56:56 +01:00
parent a88d7b4ea0
commit e3acc53e20
2 changed files with 411 additions and 110 deletions

View file

@ -412,11 +412,110 @@ async def api_create_journal_entry(
data: CreateJournalEntry,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> JournalEntry:
"""Create a new journal entry"""
try:
return await create_journal_entry(data, wallet.wallet.id)
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
"""
Create a new generic journal entry.
Submits entry to Fava/Beancount.
"""
from .fava_client import get_fava_client
from .beancount_format import format_transaction, format_posting_with_cost
# Validate that entry balances to zero
total = sum(line.amount for line in data.lines)
if total != 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Entry does not balance (total: {total}, expected: 0)"
)
# Get all accounts and validate they exist
account_map = {}
for line in data.lines:
account = await get_account(line.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account '{line.account_id}' not found"
)
account_map[line.account_id] = account
# Format postings
postings = []
for line in data.lines:
account = account_map[line.account_id]
# Extract fiat info from metadata if present
fiat_currency = line.metadata.get("fiat_currency")
fiat_amount_str = line.metadata.get("fiat_amount")
fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None
# Create posting metadata (excluding fiat fields that go in cost basis)
posting_metadata = {k: v for k, v in line.metadata.items()
if k not in ["fiat_currency", "fiat_amount"]}
if line.description:
posting_metadata["description"] = line.description
posting = format_posting_with_cost(
account=account.name,
amount_sats=line.amount,
fiat_currency=fiat_currency,
fiat_amount=abs(fiat_amount) if fiat_amount else None,
metadata=posting_metadata if posting_metadata else None
)
postings.append(posting)
# Extract tags and links from meta
tags = data.meta.get("tags", [])
links = data.meta.get("links", [])
if data.reference:
links.append(data.reference)
# Entry metadata (excluding tags and links which go at transaction level)
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
entry_meta["source"] = "castle-api"
entry_meta["created-by"] = wallet.wallet.id
# Format as Beancount entry
fava = get_fava_client()
entry = format_transaction(
date_val=data.entry_date.date() if data.entry_date else datetime.now().date(),
flag=data.flag.value if data.flag else "*",
narration=data.description,
postings=postings,
tags=tags if tags else None,
links=links if links else None,
meta=entry_meta
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}")
# Return mock JournalEntry for API compatibility
# TODO: Query Fava to get the actual entry back with its hash
timestamp = datetime.now().timestamp()
return JournalEntry(
id=f"fava-{timestamp}",
description=data.description,
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.id,
created_at=datetime.now(),
reference=data.reference,
flag=data.flag if data.flag else JournalEntryFlag.CLEARED,
lines=[
EntryLine(
id=f"fava-{timestamp}-{i}",
journal_entry_id=f"fava-{timestamp}",
account_id=line.account_id,
amount=line.amount,
description=line.description,
metadata=line.metadata
)
for i, line in enumerate(data.lines)
],
meta={**data.meta, "source": "fava", "fava_response": result.get('data', 'Unknown')}
)
# ===== SIMPLIFIED ENTRY ENDPOINTS =====
@ -530,29 +629,64 @@ async def api_create_expense_entry(
"is_equity": data.is_equity,
}
entry_data = CreateJournalEntry(
description=data.description + description_suffix,
reference=data.reference,
entry_date=data.entry_date,
flag=JournalEntryFlag.PENDING, # Expenses require admin approval
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=expense_account.id,
amount=amount_sats, # Positive = debit (expense increase)
description=f"Expense paid by user {wallet.wallet.user[:8]}",
metadata=metadata,
),
CreateEntryLine(
account_id=user_account.id,
amount=-amount_sats, # Negative = credit (liability/equity increase)
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
metadata=metadata,
),
],
# Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_expense_entry
fava = get_fava_client()
# Extract fiat info from metadata
fiat_currency = metadata.get("fiat_currency") if metadata else None
fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None
# Format Beancount entry
entry = format_expense_entry(
user_id=wallet.wallet.user,
expense_account=expense_account.name,
user_account=user_account.name,
amount_sats=amount_sats,
description=data.description,
entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
is_equity=data.is_equity,
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=data.reference
)
return await create_journal_entry(entry_data, wallet.wallet.id)
# Submit to Fava
result = await fava.add_entry(entry)
# Return a JournalEntry-like response for compatibility
# TODO: Query Fava to get the actual entry back with its hash
from .models import EntryLine
return JournalEntry(
id=f"fava-{datetime.now().timestamp()}", # Temporary ID
description=data.description + description_suffix,
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.id,
created_at=datetime.now(),
reference=data.reference,
flag=JournalEntryFlag.PENDING,
meta=entry_meta,
lines=[
EntryLine(
id=f"line-1-{datetime.now().timestamp()}",
journal_entry_id=f"fava-{datetime.now().timestamp()}",
account_id=expense_account.id,
amount=amount_sats,
description=f"Expense paid by user {wallet.wallet.user[:8]}",
metadata=metadata or {}
),
EntryLine(
id=f"line-2-{datetime.now().timestamp()}",
journal_entry_id=f"fava-{datetime.now().timestamp()}",
account_id=user_account.id,
amount=-amount_sats,
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
metadata=metadata or {}
),
]
)
@castle_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
@ -615,28 +749,62 @@ async def api_create_receivable_entry(
"debtor_user_id": data.user_id,
}
entry_data = CreateJournalEntry(
description=data.description + description_suffix,
reference=data.reference,
flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=user_receivable.id,
amount=amount_sats, # Positive = debit (asset increase - user owes castle)
description=f"Amount owed by user {data.user_id[:8]}",
metadata=metadata,
),
CreateEntryLine(
account_id=revenue_account.id,
amount=-amount_sats, # Negative = credit (revenue increase)
description="Revenue earned",
metadata=metadata,
),
],
# Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_receivable_entry
fava = get_fava_client()
# Extract fiat info from metadata
fiat_currency = metadata.get("fiat_currency") if metadata else None
fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None
# Format Beancount entry
entry = format_receivable_entry(
user_id=data.user_id,
revenue_account=revenue_account.name,
receivable_account=user_receivable.name,
amount_sats=amount_sats,
description=data.description,
entry_date=datetime.now().date(),
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=data.reference
)
return await create_journal_entry(entry_data, wallet.wallet.id)
# Submit to Fava
result = await fava.add_entry(entry)
# Return a JournalEntry-like response for compatibility
from .models import EntryLine
return JournalEntry(
id=f"fava-{datetime.now().timestamp()}",
description=data.description + description_suffix,
entry_date=datetime.now(),
created_by=wallet.wallet.id,
created_at=datetime.now(),
reference=data.reference,
flag=JournalEntryFlag.PENDING,
meta=entry_meta,
lines=[
EntryLine(
id=f"line-1-{datetime.now().timestamp()}",
journal_entry_id=f"fava-{datetime.now().timestamp()}",
account_id=user_receivable.id,
amount=amount_sats,
description=f"Amount owed by user {data.user_id[:8]}",
metadata=metadata or {}
),
EntryLine(
id=f"line-2-{datetime.now().timestamp()}",
journal_entry_id=f"fava-{datetime.now().timestamp()}",
account_id=revenue_account.id,
amount=-amount_sats,
description="Revenue earned",
metadata=metadata or {}
),
]
)
@castle_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
@ -647,7 +815,12 @@ async def api_create_revenue_entry(
"""
Create a revenue entry (castle receives payment).
Admin only.
Submits entry to Fava/Beancount.
"""
from .fava_client import get_fava_client
from .beancount_format import format_revenue_entry
# Get revenue account
revenue_account = await get_account_by_name(data.revenue_account)
if not revenue_account:
@ -668,26 +841,76 @@ async def api_create_revenue_entry(
detail=f"Payment account '{data.payment_method_account}' not found",
)
# Create journal entry
# DR Cash/Lightning/Bank, CR Revenue
entry_data = CreateJournalEntry(
# Handle currency conversion if provided
amount_sats = int(data.amount)
fiat_currency = None
fiat_amount = None
if data.currency:
# Validate currency
if data.currency.upper() not in allowed_currencies():
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}",
)
# Store fiat info for cost basis
fiat_currency = data.currency.upper()
fiat_amount = data.amount # Original fiat amount
# In this case, data.amount should be the satoshi amount
# This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat
# For now, assume if currency is provided, amount is fiat and needs conversion
# TODO: Consider updating the API model to be clearer about this
# Format as Beancount entry and submit to Fava
fava = get_fava_client()
entry = format_revenue_entry(
payment_account=payment_account.name,
revenue_account=revenue_account.name,
amount_sats=amount_sats,
description=data.description,
reference=data.reference,
lines=[
CreateEntryLine(
account_id=payment_account.id,
amount=data.amount, # Positive = debit (asset increase)
description="Payment received",
),
CreateEntryLine(
account_id=revenue_account.id,
amount=-data.amount, # Negative = credit (revenue increase)
description="Revenue earned",
),
],
entry_date=datetime.now().date(),
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=data.reference
)
return await create_journal_entry(entry_data, wallet.wallet.id)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}")
# Return mock JournalEntry for API compatibility
# TODO: Query Fava to get the actual entry back with its hash
timestamp = datetime.now().timestamp()
return JournalEntry(
id=f"fava-{timestamp}",
description=data.description,
entry_date=datetime.now(),
created_by=wallet.wallet.id,
created_at=datetime.now(),
reference=data.reference,
flag=JournalEntryFlag.CLEARED, # Revenue entries are cleared
lines=[
EntryLine(
id=f"fava-{timestamp}-1",
journal_entry_id=f"fava-{timestamp}",
account_id=payment_account.id,
amount=amount_sats,
description="Payment received",
metadata={"fiat_currency": fiat_currency, "fiat_amount": str(fiat_amount)} if fiat_currency else {}
),
EntryLine(
id=f"fava-{timestamp}-2",
journal_entry_id=f"fava-{timestamp}",
account_id=revenue_account.id,
amount=-amount_sats,
description="Revenue earned",
metadata={}
)
],
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
)
# ===== USER BALANCE ENDPOINTS =====
@ -1609,31 +1832,31 @@ async def api_approve_manual_payment_request(
detail="Lightning account not found",
)
# Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased)
# This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes
journal_entry = await create_journal_entry(
CreateJournalEntry(
description=f"Manual payment to user: {request.description}",
reference=f"MPR-{request.id}",
lines=[
CreateEntryLine(
account_id=liability_account.id,
amount=request.amount, # Positive = debit (liability decrease - castle owes less)
description="Payment to user",
),
CreateEntryLine(
account_id=lightning_account.id,
amount=-request.amount, # Negative = credit (asset decrease)
description="Payment from castle",
),
],
),
castle_wallet_id,
# Format payment entry and submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
fava = get_fava_client()
entry = format_payment_entry(
user_id=request.user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=liability_account.name,
amount_sats=request.amount,
description=f"Manual payment to user: {request.description}",
entry_date=datetime.now().date(),
is_payable=True, # Castle paying user
reference=f"MPR-{request.id}"
)
# Approve the request
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
# Approve the request with Fava entry reference
entry_id = f"fava-{datetime.now().timestamp()}"
return await approve_manual_payment_request(
request_id, wallet.wallet.user, journal_entry.id
request_id, wallet.wallet.user, entry_id
)
@ -1675,8 +1898,13 @@ async def api_reject_manual_payment_request(
async def api_approve_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""Approve a pending expense entry (admin only)"""
) -> dict:
"""
Approve a pending expense entry (admin only).
With Fava integration, entries must be approved through Fava UI or API.
This endpoint provides instructions on how to approve entries.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
@ -1685,33 +1913,23 @@ async def api_approve_expense_entry(
detail="Only super user can approve expenses",
)
# Get the entry
entry = await get_journal_entry(entry_id)
if not entry:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Journal entry not found",
)
# TODO: Implement Fava entry update via PUT /api/source_slice
# This requires:
# 1. Query Fava for entry by link (^castle-{entry_id} or similar)
# 2. Get the entry's source text
# 3. Change flag from ! to *
# 4. Submit updated source back to Fava
if entry.flag != JournalEntryFlag.PENDING:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Entry is not pending (current status: {entry.flag.value})",
# For now, return instructions
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail=(
f"Entry approval via API not yet implemented with Fava integration. "
f"To approve entry {entry_id}, open Fava and edit the transaction to change the flag from '!' to '*'. "
f"Fava URL: http://localhost:3333/castle-ledger/"
)
# Update flag to cleared
await db.execute(
"""
UPDATE journal_entries
SET flag = :flag
WHERE id = :id
""",
{"flag": JournalEntryFlag.CLEARED.value, "id": entry_id}
)
# Return updated entry
return await get_journal_entry(entry_id)
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
async def api_reject_expense_entry(