Updates the expense tracking system to store payables and receivables in fiat currency within Beancount. This ensures accurate debt representation and simplifies balance calculations. Changes include: - Converting `format_expense_entry` and `format_receivable_entry` to use fiat amounts. - Introducing `format_net_settlement_entry` for net settlement payments. - Modifying `format_payment_entry` to use cost syntax for fiat tracking. - Adjusting Fava client to correctly process new amount formats and metadata. - Adding average cost basis posting format The use of fiat amounts and cost basis aims to provide better accuracy and compatibility with existing Beancount workflows.
260 lines
9.4 KiB
Python
260 lines
9.4 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_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
|