Migrates payment processing to Fava

Removes direct journal entry creation in favor of using Fava for accounting.

This change centralizes accounting logic in Fava, improving auditability and consistency.
It replaces direct database interactions for recording payments and settlements with calls to the Fava client.
The changes also refactor balance retrieval to fetch data from Fava.
This commit is contained in:
padreug 2025-11-09 23:04:26 +01:00
parent e3acc53e20
commit efc09aa5ce

View file

@ -18,13 +18,11 @@ from .crud import (
create_account, create_account,
create_account_permission, create_account_permission,
create_balance_assertion, create_balance_assertion,
create_journal_entry,
create_manual_payment_request, create_manual_payment_request,
db, db,
delete_account_permission, delete_account_permission,
delete_balance_assertion, delete_balance_assertion,
get_account, get_account,
get_account_balance,
get_account_by_name, get_account_by_name,
get_account_permission, get_account_permission,
get_account_permissions, get_account_permissions,
@ -32,7 +30,6 @@ from .crud import (
get_all_accounts, get_all_accounts,
get_all_journal_entries, get_all_journal_entries,
get_all_manual_payment_requests, get_all_manual_payment_requests,
get_all_user_balances,
get_all_user_wallet_settings, get_all_user_wallet_settings,
get_balance_assertion, get_balance_assertion,
get_balance_assertions, get_balance_assertions,
@ -40,7 +37,6 @@ from .crud import (
get_journal_entry, get_journal_entry,
get_manual_payment_request, get_manual_payment_request,
get_or_create_user_account, get_or_create_user_account,
get_user_balance,
get_user_manual_payment_requests, get_user_manual_payment_requests,
get_user_permissions, get_user_permissions,
get_user_permissions_with_inheritance, get_user_permissions_with_inheritance,
@ -1045,8 +1041,19 @@ async def api_generate_payment_invoice(
# Get castle wallet ID # Get castle wallet ID
castle_wallet_id = await check_castle_wallet_configured() castle_wallet_id = await check_castle_wallet_configured()
# Get user's balance to calculate fiat metadata # Get user's balance from Fava to calculate fiat metadata
user_balance = await get_user_balance(target_user_id) from .fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance(target_user_id)
# Build UserBalance object for compatibility
user_balance = UserBalance(
user_id=target_user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"]
)
# Calculate proportional fiat amount for this invoice # Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "castle", "user_id": target_user_id} invoice_extra = {"tag": "castle", "user_id": target_user_id}
@ -1147,36 +1154,47 @@ async def api_record_payment(
detail="Payment metadata missing user_id. Cannot determine which user to credit.", detail="Payment metadata missing user_id. Cannot determine which user to credit.",
) )
# Check if payment already recorded (idempotency) # Check if payment already recorded in Fava (idempotency)
from .crud import get_journal_entry_by_reference from .fava_client import get_fava_client
existing = await get_journal_entry_by_reference(data.payment_hash) from .beancount_format import format_payment_entry
if existing: import httpx
# Payment already recorded, return existing entry
balance = await get_user_balance(target_user_id) fava = get_fava_client()
return {
"journal_entry_id": existing.id, # Query Fava for existing entry with this payment hash link
"new_balance": balance.balance, query = f"SELECT * WHERE links ~ 'ln-{data.payment_hash[:16]}'"
"message": "Payment already recorded", try:
} 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'):
# Payment already recorded, return existing entry
balance_data = await fava.get_user_balance(target_user_id)
return {
"journal_entry_id": f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
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
# 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)
line_metadata = {} fiat_currency = None
fiat_amount = None
if payment.extra and isinstance(payment.extra, dict): if payment.extra and isinstance(payment.extra, dict):
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") from decimal import Decimal
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(
@ -1190,47 +1208,31 @@ async def api_record_payment(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
) )
# Create journal entry to record payment # Format payment entry and submit to Fava
# DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User) entry = format_payment_entry(
# This reduces what the user owes user_id=target_user_id,
payment_account=lightning_account.name,
# Add meta information for audit trail payable_or_receivable_account=user_receivable.name,
entry_meta = { amount_sats=amount_sats,
"source": "lightning_payment",
"created_via": "record_payment",
"payment_hash": data.payment_hash,
"payer_user_id": target_user_id,
}
entry_data = CreateJournalEntry(
description=f"Lightning payment from user {target_user_id[:8]}", description=f"Lightning payment from user {target_user_id[:8]}",
reference=data.payment_hash, entry_date=datetime.now().date(),
flag=JournalEntryFlag.CLEARED, # Payment is immediately cleared is_payable=False, # User paying castle (receivable settlement)
meta=entry_meta, fiat_currency=fiat_currency,
lines=[ fiat_amount=fiat_amount,
CreateEntryLine( payment_hash=data.payment_hash,
account_id=lightning_account.id, reference=data.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, target_user_id) # Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance # Get updated balance from Fava
balance = await get_user_balance(target_user_id) balance_data = await fava.get_user_balance(target_user_id)
return { return {
"journal_entry_id": entry.id, "journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance.balance, "new_balance": balance_data["balance"],
"message": "Payment recorded successfully", "message": "Payment recorded successfully",
} }
@ -1257,32 +1259,34 @@ async def api_pay_user(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
) )
# Create journal entry # Format payment entry and submit to Fava
# DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning # DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning
entry_data = CreateJournalEntry( from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
fava = get_fava_client()
entry = format_payment_entry(
user_id=user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount,
description=f"Payment to user {user_id[:8]}", description=f"Payment to user {user_id[:8]}",
lines=[ entry_date=datetime.now().date(),
CreateEntryLine( is_payable=True, # Castle paying user
account_id=user_payable.id, reference=f"PAY-{user_id[:8]}"
amount=amount, # Positive = debit (liability decrease)
description="Payment made to user",
),
CreateEntryLine(
account_id=lightning_account.id,
amount=-amount, # Negative = credit (asset decrease)
description="Lightning payment sent",
),
],
) )
entry = await create_journal_entry(entry_data, wallet.wallet.id) # Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance # Get updated balance from Fava
balance = await get_user_balance(user_id) balance_data = await fava.get_user_balance(user_id)
return { return {
"journal_entry": entry.dict(), "journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance.balance, "new_balance": balance_data["balance"],
"message": "Payment recorded successfully", "message": "Payment recorded successfully",
} }
@ -1351,14 +1355,16 @@ async def api_settle_receivable(
detail=f"Payment account '{account_name}' not found. Please create it first.", detail=f"Payment account '{account_name}' not found. Please create it first.",
) )
# Create journal entry # Format settlement entry and submit to Fava
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt # This records that user paid their debt
from .fava_client import get_fava_client
# Determine the amount to record in the journal from .beancount_format import format_payment_entry
# IMPORTANT: Always record in satoshis to match the receivable account balance
from decimal import Decimal from decimal import Decimal
fava = get_fava_client()
# Determine amount and currency
if data.currency: if data.currency:
# Fiat currency payment (e.g., EUR, USD) # Fiat currency payment (e.g., EUR, USD)
# Use the sats equivalent for the journal entry to match the receivable # Use the sats equivalent for the journal entry to match the receivable
@ -1368,68 +1374,51 @@ async def api_settle_receivable(
detail="amount_sats is required when settling with fiat currency" detail="amount_sats is required when settling with fiat currency"
) )
amount_in_sats = data.amount_sats amount_in_sats = data.amount_sats
line_metadata = { fiat_currency = data.currency.upper()
"fiat_currency": data.currency.upper(), fiat_amount = data.amount
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
}
else: else:
# Satoshi payment # Satoshi payment
amount_in_sats = int(data.amount) amount_in_sats = int(data.amount)
line_metadata = {} fiat_currency = None
fiat_amount = None
# Add payment hash for lightning payments # Format payment entry
if data.payment_hash: entry = format_payment_entry(
line_metadata["payment_hash"] = data.payment_hash user_id=data.user_id,
payment_account=payment_account.name,
# Add transaction ID for on-chain Bitcoin payments payable_or_receivable_account=user_receivable.name,
if data.txid: amount_sats=amount_in_sats,
line_metadata["txid"] = data.txid
# Add meta information for audit trail
entry_meta = {
"source": "manual_settlement",
"payment_method": data.payment_method,
"settled_by": wallet.wallet.user,
"payer_user_id": data.user_id,
}
if data.currency:
entry_meta["currency"] = data.currency
entry_data = CreateJournalEntry(
description=data.description, description=data.description,
reference=data.reference or f"MANUAL-{data.user_id[:8]}", entry_date=datetime.now().date(),
flag=JournalEntryFlag.CLEARED, # Manual payments are immediately cleared is_payable=False, # User paying castle (receivable settlement)
meta=entry_meta, fiat_currency=fiat_currency,
lines=[ fiat_amount=fiat_amount,
CreateEntryLine( payment_hash=data.payment_hash,
account_id=payment_account.id, reference=data.reference or f"MANUAL-{data.user_id[:8]}"
amount=amount_in_sats, # Positive = debit (asset increase)
description=f"Payment received via {data.payment_method}",
metadata=line_metadata,
),
CreateEntryLine(
account_id=user_receivable.id,
amount=-amount_in_sats, # Negative = credit (asset decrease - receivable settled)
description="Receivable settled",
metadata=line_metadata,
),
],
) )
entry = await create_journal_entry(entry_data, wallet.wallet.id) # Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = wallet.wallet.user
if data.txid:
entry["meta"]["txid"] = data.txid
# Get updated balance # Submit to Fava
balance = await get_user_balance(data.user_id) result = await fava.add_entry(entry)
logger.info(f"Receivable settlement submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance(data.user_id)
return { return {
"journal_entry_id": entry.id, "journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id, "user_id": data.user_id,
"amount_settled": float(data.amount), "amount_settled": float(data.amount),
"currency": data.currency, "currency": data.currency,
"payment_method": data.payment_method, "payment_method": data.payment_method,
"new_balance": balance.balance, "new_balance": balance_data["balance"],
"message": f"Receivable settled successfully via {data.payment_method}", "message": f"Receivable settled successfully via {data.payment_method}",
} }
@ -1496,10 +1485,16 @@ async def api_pay_user(
detail=f"Payment account '{account_name}' not found. Please create it first.", detail=f"Payment account '{account_name}' not found. Please create it first.",
) )
# Determine the amount to record in the journal # Format payment entry and submit to Fava
# IMPORTANT: Always record in satoshis to match the payable account balance # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that castle paid its debt
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
from decimal import Decimal from decimal import Decimal
fava = get_fava_client()
# Determine amount and currency
if data.currency: if data.currency:
# Fiat currency payment (e.g., EUR, USD) # Fiat currency payment (e.g., EUR, USD)
# Use the sats equivalent for the journal entry to match the payable # Use the sats equivalent for the journal entry to match the payable
@ -1509,71 +1504,51 @@ async def api_pay_user(
detail="amount_sats is required when paying with fiat currency" detail="amount_sats is required when paying with fiat currency"
) )
amount_in_sats = data.amount_sats amount_in_sats = data.amount_sats
line_metadata = { fiat_currency = data.currency.upper()
"fiat_currency": data.currency.upper(), fiat_amount = data.amount
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
}
else: else:
# Satoshi payment # Satoshi payment
amount_in_sats = int(data.amount) amount_in_sats = int(data.amount)
line_metadata = {} fiat_currency = None
fiat_amount = None
# Add payment hash for lightning payments # Format payment entry
if data.payment_hash: entry = format_payment_entry(
line_metadata["payment_hash"] = data.payment_hash user_id=data.user_id,
payment_account=payment_account.name,
# Add transaction ID for on-chain Bitcoin payments payable_or_receivable_account=user_payable.name,
if data.txid: amount_sats=amount_in_sats,
line_metadata["txid"] = data.txid
# Create journal entry
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that castle paid its debt
entry_meta = {
"source": "manual_payment" if data.payment_method != "lightning" else "lightning_payment",
"payment_method": data.payment_method,
"paid_by": wallet.wallet.user,
"payee_user_id": data.user_id,
}
if data.currency:
entry_meta["currency"] = data.currency
entry_data = CreateJournalEntry(
description=data.description or f"Payment to user via {data.payment_method}", description=data.description or f"Payment to user via {data.payment_method}",
reference=data.reference or f"PAY-{data.user_id[:8]}", entry_date=datetime.now().date(),
flag=JournalEntryFlag.CLEARED, # Payments are immediately cleared is_payable=True, # Castle paying user (payable settlement)
meta=entry_meta, fiat_currency=fiat_currency,
lines=[ fiat_amount=fiat_amount,
CreateEntryLine( payment_hash=data.payment_hash,
account_id=user_payable.id, reference=data.reference or f"PAY-{data.user_id[:8]}"
amount=amount_in_sats, # Positive = debit (liability decrease)
description="Payable settled",
metadata=line_metadata,
),
CreateEntryLine(
account_id=payment_account.id,
amount=-amount_in_sats, # Negative = credit (asset decrease)
description=f"Payment sent via {data.payment_method}",
metadata=line_metadata,
),
],
) )
entry = await create_journal_entry(entry_data, wallet.wallet.id) # Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = wallet.wallet.user
if data.txid:
entry["meta"]["txid"] = data.txid
# Get updated balance # Submit to Fava
balance = await get_user_balance(data.user_id) result = await fava.add_entry(entry)
logger.info(f"Payable payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance(data.user_id)
return { return {
"journal_entry_id": entry.id, "journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id, "user_id": data.user_id,
"amount_paid": float(data.amount), "amount_paid": float(data.amount),
"currency": data.currency, "currency": data.currency,
"payment_method": data.payment_method, "payment_method": data.payment_method,
"new_balance": balance.balance, "new_balance": balance_data["balance"],
"message": f"User paid successfully via {data.payment_method}", "message": f"User paid successfully via {data.payment_method}",
} }