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.
This commit is contained in:
padreug 2025-11-09 22:29:23 +01:00
parent 3c925abe9e
commit ff27f7ba01
2 changed files with 54 additions and 173 deletions

108
tasks.py
View file

@ -129,11 +129,11 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: 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 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 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 # Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle": if not payment.extra or payment.extra.get("tag") != "castle":
@ -145,38 +145,49 @@ async def on_invoice_paid(payment: Payment) -> None:
return return
# Check if payment already recorded (idempotency) # Check if payment already recorded (idempotency)
from .crud import get_journal_entry_by_reference # Query Fava for existing entry with this payment hash link
existing = await get_journal_entry_by_reference(payment.payment_hash) from .fava_client import get_fava_client
if existing: import httpx
logger.info(f"Payment {payment.payment_hash} already recorded, skipping")
return
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}") fava = get_fava_client()
try: try:
# Import here to avoid circular dependencies # Query Fava for existing payment entry
from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account query = f"SELECT * WHERE links ~ 'ln-{payment.payment_hash[:16]}'"
from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag 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 # Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000 amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present) # Extract fiat metadata from invoice (if present)
from decimal import Decimal fiat_currency = None
line_metadata = {} fiat_amount = None
if payment.extra: if payment.extra:
fiat_currency = payment.extra.get("fiat_currency") fiat_currency = payment.extra.get("fiat_currency")
fiat_amount = payment.extra.get("fiat_amount") fiat_amount_str = payment.extra.get("fiat_amount")
fiat_rate = payment.extra.get("fiat_rate") if fiat_amount_str:
btc_rate = payment.extra.get("btc_rate") fiat_amount = Decimal(str(fiat_amount_str))
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,
}
# Get user's receivable account (what user owes) # Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account( 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") logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return return
# Create journal entry to record payment # Format as Beancount transaction
# DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User) entry = format_payment_entry(
# This reduces what the user owes user_id=user_id,
entry_meta = { payment_account=lightning_account.name, # "Assets:Bitcoin:Lightning"
"source": "lightning_payment", payable_or_receivable_account=user_receivable.name, # "Assets:Receivable:User-{id}"
"created_via": "auto_invoice_listener", amount_sats=amount_sats,
"payment_hash": payment.payment_hash,
"payer_user_id": user_id,
}
entry_data = CreateJournalEntry(
description=f"Lightning payment from user {user_id[:8]}", description=f"Lightning payment from user {user_id[:8]}",
reference=payment.payment_hash, entry_date=datetime.now().date(),
flag=JournalEntryFlag.CLEARED, is_payable=False, # User paying castle (receivable settlement)
meta=entry_meta, fiat_currency=fiat_currency,
lines=[ fiat_amount=fiat_amount,
CreateEntryLine( payment_hash=payment.payment_hash,
account_id=lightning_account.id, reference=payment.payment_hash
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 = await create_journal_entry(entry_data, user_id) # Submit to Fava
logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}") 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: except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")

View file

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