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:
parent
3c925abe9e
commit
ff27f7ba01
2 changed files with 54 additions and 173 deletions
108
tasks.py
108
tasks.py
|
|
@ -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}")
|
||||||
|
|
|
||||||
119
tasks_fava.py
119
tasks_fava.py
|
|
@ -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!
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue