From ff27f7ba01ecead53d769b879d25104f5687bbfe Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 9 Nov 2025 22:29:23 +0100 Subject: [PATCH] Submits Castle payments to Fava Refactors the payment processing logic to submit journal entries directly to Fava/Beancount instead of storing them in the Castle database. It queries Fava to prevent duplicate entries. The changes include extracting fiat metadata from the invoice, formatting the data as a Beancount transaction using a dedicated formatting function, and submitting it to the Fava API. --- tasks.py | 108 ++++++++++++++++++++++----------------------- tasks_fava.py | 119 -------------------------------------------------- 2 files changed, 54 insertions(+), 173 deletions(-) delete mode 100644 tasks_fava.py diff --git a/tasks.py b/tasks.py index 3dcb2ca..0bec668 100644 --- a/tasks.py +++ b/tasks.py @@ -129,11 +129,11 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: """ - Handle a paid Castle invoice by automatically creating a journal entry. + Handle a paid Castle invoice by automatically submitting to Fava. This function is called automatically when any invoice on the Castle wallet is paid. It checks if the invoice is a Castle payment and records it in - the accounting system. + Beancount via Fava. """ # Only process Castle-specific payments if not payment.extra or payment.extra.get("tag") != "castle": @@ -145,38 +145,49 @@ async def on_invoice_paid(payment: Payment) -> None: return # Check if payment already recorded (idempotency) - from .crud import get_journal_entry_by_reference - existing = await get_journal_entry_by_reference(payment.payment_hash) - if existing: - logger.info(f"Payment {payment.payment_hash} already recorded, skipping") - return + # Query Fava for existing entry with this payment hash link + from .fava_client import get_fava_client + import httpx - logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}") + fava = get_fava_client() try: - # Import here to avoid circular dependencies - from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account - from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag + # Query Fava for existing payment entry + query = f"SELECT * WHERE links ~ 'ln-{payment.payment_hash[:16]}'" + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{fava.base_url}/query", + params={"query_string": query} + ) + result = response.json() + + if result.get('data', {}).get('rows'): + logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping") + return + + except Exception as e: + logger.warning(f"Could not check Fava for duplicate payment: {e}") + # Continue anyway - Fava/Beancount will catch duplicate if it exists + + logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") + + try: + from decimal import Decimal + from .crud import get_account_by_name, get_or_create_user_account + from .models import AccountType + from .beancount_format import format_payment_entry # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 # Extract fiat metadata from invoice (if present) - from decimal import Decimal - line_metadata = {} + fiat_currency = None + fiat_amount = None if payment.extra: fiat_currency = payment.extra.get("fiat_currency") - fiat_amount = payment.extra.get("fiat_amount") - fiat_rate = payment.extra.get("fiat_rate") - btc_rate = payment.extra.get("btc_rate") - - if fiat_currency and fiat_amount: - line_metadata = { - "fiat_currency": fiat_currency, - "fiat_amount": str(fiat_amount), - "fiat_rate": fiat_rate, - "btc_rate": btc_rate, - } + fiat_amount_str = payment.extra.get("fiat_amount") + if fiat_amount_str: + fiat_amount = Decimal(str(fiat_amount_str)) # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( @@ -189,39 +200,28 @@ async def on_invoice_paid(payment: Payment) -> None: logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") return - # Create journal entry to record payment - # DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User) - # This reduces what the user owes - entry_meta = { - "source": "lightning_payment", - "created_via": "auto_invoice_listener", - "payment_hash": payment.payment_hash, - "payer_user_id": user_id, - } - - entry_data = CreateJournalEntry( + # Format as Beancount transaction + entry = format_payment_entry( + user_id=user_id, + payment_account=lightning_account.name, # "Assets:Bitcoin:Lightning" + payable_or_receivable_account=user_receivable.name, # "Assets:Receivable:User-{id}" + amount_sats=amount_sats, description=f"Lightning payment from user {user_id[:8]}", - reference=payment.payment_hash, - flag=JournalEntryFlag.CLEARED, - meta=entry_meta, - lines=[ - CreateEntryLine( - account_id=lightning_account.id, - amount=amount_sats, # Positive = debit (asset increase) - description="Lightning payment received", - metadata=line_metadata, - ), - CreateEntryLine( - account_id=user_receivable.id, - amount=-amount_sats, # Negative = credit (asset decrease - receivable settled) - description="Payment applied to balance", - metadata=line_metadata, - ), - ], + entry_date=datetime.now().date(), + is_payable=False, # User paying castle (receivable settlement) + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + payment_hash=payment.payment_hash, + reference=payment.payment_hash ) - entry = await create_journal_entry(entry_data, user_id) - logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}") + # Submit to Fava + result = await fava.add_entry(entry) + + logger.info( + f"Successfully recorded payment {payment.payment_hash} to Fava: " + f"{result.get('data', 'Unknown')}" + ) except Exception as e: logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") diff --git a/tasks_fava.py b/tasks_fava.py deleted file mode 100644 index 02d8db7..0000000 --- a/tasks_fava.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Updated tasks.py for Fava integration. - -This shows how on_invoice_paid() should be modified to submit to Fava -instead of storing in Castle DB. -""" - -from decimal import Decimal -from datetime import datetime -from lnbits.core.models import Payment -from loguru import logger - -from .fava_client import get_fava_client -from .beancount_format import format_payment_entry -from .crud import get_account_by_name, get_or_create_user_account -from .models import AccountType - - -async def on_invoice_paid_fava(payment: Payment) -> None: - """ - Handle a paid Castle invoice by automatically submitting to Fava. - - This function is called automatically when any invoice on the Castle wallet - is paid. It checks if the invoice is a Castle payment and records it in - Beancount via Fava. - - Key differences from original: - - NO database storage in Castle - - Formats as Beancount transaction - - Submits to Fava API - - Fava writes to Beancount file - """ - # Only process Castle-specific payments - if not payment.extra or payment.extra.get("tag") != "castle": - return - - user_id = payment.extra.get("user_id") - if not user_id: - logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata") - return - - # Check if payment already recorded (idempotency) - # NOTE: With Fava, we need to query Fava instead of Castle DB! - fava = get_fava_client() - - try: - # Query Fava for existing entry with this payment hash - query = f"SELECT * WHERE links ~ 'ln-{payment.payment_hash[:16]}'" - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get( - f"{fava.base_url}/query", - params={"query_string": query} - ) - result = response.json() - - if result.get('data', {}).get('rows'): - logger.info(f"Payment {payment.payment_hash} already recorded, skipping") - return - - except Exception as e: - logger.warning(f"Could not check for duplicate payment: {e}") - # Continue anyway - Fava/Beancount will catch duplicate if it exists - - logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}") - - try: - # Convert amount from millisatoshis to satoshis - amount_sats = payment.amount // 1000 - - # Extract fiat metadata from invoice (if present) - fiat_currency = None - fiat_amount = None - if payment.extra: - fiat_currency = payment.extra.get("fiat_currency") - fiat_amount_str = payment.extra.get("fiat_amount") - if fiat_amount_str: - fiat_amount = Decimal(str(fiat_amount_str)) - - # Get user's receivable account (what user owes) - user_receivable = await get_or_create_user_account( - user_id, AccountType.ASSET, "Accounts Receivable" - ) - - # Get lightning account - lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") - if not lightning_account: - logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") - return - - # Format as Beancount transaction - entry = format_payment_entry( - user_id=user_id, - payment_account=lightning_account.name, # "Assets:Bitcoin:Lightning" - payable_or_receivable_account=user_receivable.name, # "Assets:Receivable:User-{id}" - amount_sats=amount_sats, - description=f"Lightning payment from user {user_id[:8]}", - entry_date=datetime.now().date(), - is_payable=False, # User paying castle (receivable settlement) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - payment_hash=payment.payment_hash, - reference=payment.payment_hash # For linking - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - logger.info( - f"Successfully recorded payment {payment.payment_hash} to Fava: " - f"{result.get('data', 'Unknown')}" - ) - - except Exception as e: - logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") - raise - - -# ALSO UPDATE: on_invoice_paid() in tasks.py -# Replace the entire function body (lines 130-228) with the code above!