diff --git a/tasks_fava.py b/tasks_fava.py new file mode 100644 index 0000000..02d8db7 --- /dev/null +++ b/tasks_fava.py @@ -0,0 +1,119 @@ +""" +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!