castle/tasks_fava.py
padreug 3c925abe9e Adds Fava integration for invoice payments
Implements a new handler to process Castle invoice payments by submitting them to Fava, which in turn writes them to a Beancount file.

This approach avoids storing payment data directly in the Castle database. The handler formats the payment as a Beancount transaction, includes fiat currency if available, and queries Fava to prevent duplicate entries.

The commit also updates documentation to reflect the changes to the invoice processing workflow.
2025-11-10 01:06:51 +01:00

119 lines
4.2 KiB
Python

"""
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!