Refactors user balance calculation to directly parse journal entries, enhancing accuracy and efficiency. This change eliminates reliance on direct database queries and provides a more reliable mechanism for determining user balances. Adds logging for debugging purposes. Also extracts and uses fiat metadata from invoice/payment extras.
232 lines
8.2 KiB
Python
232 lines
8.2 KiB
Python
"""
|
|
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_payment_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))
|
|
|
|
logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}")
|
|
|
|
# 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
|
|
)
|
|
|
|
# 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
|