""" Background tasks for Castle accounting extension. These tasks handle automated reconciliation checks and maintenance. """ import asyncio from asyncio import Queue from datetime import datetime from typing import Optional from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener from loguru import logger from .crud import check_balance_assertion, get_balance_assertions from .models import AssertionStatus async def check_all_balance_assertions() -> dict: """ Check all balance assertions and return results. This can be called manually or scheduled to run daily. Returns: dict: Summary of check results """ from lnbits.helpers import urlsafe_short_hash # Get all assertions all_assertions = await get_balance_assertions(limit=1000) results = { "task_id": urlsafe_short_hash(), "timestamp": datetime.now().isoformat(), "total": len(all_assertions), "checked": 0, "passed": 0, "failed": 0, "errors": 0, "failed_assertions": [], } for assertion in all_assertions: try: checked = await check_balance_assertion(assertion.id) results["checked"] += 1 if checked.status == AssertionStatus.PASSED: results["passed"] += 1 elif checked.status == AssertionStatus.FAILED: results["failed"] += 1 results["failed_assertions"].append({ "id": assertion.id, "account_id": assertion.account_id, "expected_sats": assertion.expected_balance_sats, "actual_sats": checked.checked_balance_sats, "difference_sats": checked.difference_sats, }) except Exception as e: results["errors"] += 1 print(f"Error checking assertion {assertion.id}: {e}") # Log results if results["failed"] > 0: print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!") for failed in results["failed_assertions"]: print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}") else: print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓") return results async def scheduled_daily_reconciliation(): """ Scheduled task that runs daily to check all balance assertions. This function is meant to be called by a scheduler (cron, systemd timer, etc.) or by LNbits background task system. """ print(f"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}") try: results = await check_all_balance_assertions() # TODO: Send notifications if there are failures # This could send email, webhook, or in-app notification if results["failed"] > 0: print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!") # Future: Send alert notification return results except Exception as e: print(f"[CASTLE] Error in scheduled reconciliation: {e}") raise def start_daily_reconciliation_task(): """ Initialize the daily reconciliation task. This can be called from the extension's __init__.py or configured to run via external cron job. For cron setup: # Run daily at 2 AM 0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" """ print("[CASTLE] Daily reconciliation task registered") # In a production system, you would register this with LNbits task scheduler # For now, it can be triggered manually via API endpoint async def wait_for_paid_invoices(): """ Background task that listens for paid invoices and automatically records them in the accounting system. This ensures payments are recorded even if the user closes their browser before the payment is detected by client-side polling. """ invoice_queue = Queue() register_invoice_listener(invoice_queue, "ext_castle") while True: payment = await invoice_queue.get() await on_invoice_paid(payment) async def on_invoice_paid(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. """ # 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) # Query Fava for existing entry with this payment hash link from .fava_client import get_fava_client import httpx fava = get_fava_client() try: # 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_net_settlement_entry # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 # Extract fiat metadata from invoice (if present) fiat_currency = None fiat_amount = None logger.info(f"Payment.extra in webhook: {payment.extra}") if payment.extra: fiat_currency = payment.extra.get("fiat_currency") fiat_amount_str = payment.extra.get("fiat_amount") logger.info(f"Extracted from extra - fiat_currency: {fiat_currency}, fiat_amount_str: {fiat_amount_str}") if fiat_amount_str: fiat_amount = Decimal(str(fiat_amount_str)) if not fiat_currency or not fiat_amount: logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata") return logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}") # Get user's current balance to determine receivables and payables balance = await fava.get_user_balance(user_id) fiat_balances = balance.get("fiat_balances", {}) total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) logger.info(f"User {user_id[:8]} current balance: {total_fiat_balance} {fiat_currency}") # Determine receivables and payables based on balance # Positive balance = user owes castle (receivable) # Negative balance = castle owes user (payable) if total_fiat_balance > 0: # User owes castle total_receivable = total_fiat_balance total_payable = Decimal(0) else: # Castle owes user total_receivable = Decimal(0) total_payable = abs(total_fiat_balance) logger.info(f"Settlement amounts - Receivable: {total_receivable}, Payable: {total_payable}, Net: {fiat_amount}") # Get account names user_receivable = await get_or_create_user_account( user_id, AccountType.ASSET, "Accounts Receivable" ) user_payable = await get_or_create_user_account( user_id, AccountType.LIABILITY, "Accounts Payable" ) 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 net settlement transaction entry = format_net_settlement_entry( user_id=user_id, payment_account=lightning_account.name, receivable_account=user_receivable.name, payable_account=user_payable.name, amount_sats=amount_sats, net_fiat_amount=fiat_amount, total_receivable_fiat=total_receivable, total_payable_fiat=total_payable, fiat_currency=fiat_currency, description=f"Lightning payment settlement from user {user_id[:8]}", entry_date=datetime.now().date(), payment_hash=payment.payment_hash, reference=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}") raise