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:
"""
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}")