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_permission,
create_balance_assertion,
create_journal_entry,
create_manual_payment_request,
db,
delete_account_permission,
delete_balance_assertion,
get_account,
get_account_balance,
get_account_by_name,
get_account_permission,
get_account_permissions,
@ -32,7 +30,6 @@ from .crud import (
get_all_accounts,
get_all_journal_entries,
get_all_manual_payment_requests,
get_all_user_balances,
get_all_user_wallet_settings,
get_balance_assertion,
get_balance_assertions,
@ -40,7 +37,6 @@ from .crud import (
get_journal_entry,
get_manual_payment_request,
get_or_create_user_account,
get_user_balance,
get_user_manual_payment_requests,
get_user_permissions,
get_user_permissions_with_inheritance,
@ -1045,8 +1041,19 @@ async def api_generate_payment_invoice(
# Get castle wallet ID
castle_wallet_id = await check_castle_wallet_configured()
# Get user's balance to calculate fiat metadata
user_balance = await get_user_balance(target_user_id)
# Get user's balance from Fava to calculate fiat metadata
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
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.",
)
# Check if payment already recorded (idempotency)
from .crud import get_journal_entry_by_reference
existing = await get_journal_entry_by_reference(data.payment_hash)
if existing:
# Check if payment already recorded in Fava (idempotency)
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
import httpx
fava = get_fava_client()
# Query Fava for existing entry with this payment hash link
query = f"SELECT * WHERE links ~ 'ln-{data.payment_hash[:16]}'"
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 = await get_user_balance(target_user_id)
balance_data = await fava.get_user_balance(target_user_id)
return {
"journal_entry_id": existing.id,
"new_balance": balance.balance,
"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
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
line_metadata = {}
fiat_currency = None
fiat_amount = None
if payment.extra and isinstance(payment.extra, dict):
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount = payment.extra.get("fiat_amount")
fiat_rate = payment.extra.get("fiat_rate")
btc_rate = payment.extra.get("btc_rate")
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,
}
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
from decimal import Decimal
fiat_amount = Decimal(str(fiat_amount_str))
# Get user's receivable account (what user owes)
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"
)
# Create journal entry to record payment
# DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User)
# This reduces what the user owes
# Add meta information for audit trail
entry_meta = {
"source": "lightning_payment",
"created_via": "record_payment",
"payment_hash": data.payment_hash,
"payer_user_id": target_user_id,
}
entry_data = CreateJournalEntry(
# Format payment entry and submit to Fava
entry = format_payment_entry(
user_id=target_user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_sats,
description=f"Lightning payment from user {target_user_id[:8]}",
reference=data.payment_hash,
flag=JournalEntryFlag.CLEARED, # Payment is immediately cleared
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=lightning_account.id,
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_date=datetime.now().date(),
is_payable=False, # User paying castle (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.payment_hash
)
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
balance = await get_user_balance(target_user_id)
# Get updated balance from Fava
balance_data = await fava.get_user_balance(target_user_id)
return {
"journal_entry_id": entry.id,
"new_balance": balance.balance,
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@ -1257,32 +1259,34 @@ async def api_pay_user(
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
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]}",
lines=[
CreateEntryLine(
account_id=user_payable.id,
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_date=datetime.now().date(),
is_payable=True, # Castle paying user
reference=f"PAY-{user_id[:8]}"
)
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
balance = await get_user_balance(user_id)
# Get updated balance from Fava
balance_data = await fava.get_user_balance(user_id)
return {
"journal_entry": entry.dict(),
"new_balance": balance.balance,
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance_data["balance"],
"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.",
)
# Create journal entry
# Format settlement entry and submit to Fava
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt
# Determine the amount to record in the journal
# IMPORTANT: Always record in satoshis to match the receivable account balance
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
from decimal import Decimal
fava = get_fava_client()
# Determine amount and currency
if data.currency:
# Fiat currency payment (e.g., EUR, USD)
# 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"
)
amount_in_sats = data.amount_sats
line_metadata = {
"fiat_currency": data.currency.upper(),
"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,
}
fiat_currency = data.currency.upper()
fiat_amount = data.amount
else:
# Satoshi payment
amount_in_sats = int(data.amount)
line_metadata = {}
fiat_currency = None
fiat_amount = None
# Add payment hash for lightning payments
if data.payment_hash:
line_metadata["payment_hash"] = data.payment_hash
# Add transaction ID for on-chain Bitcoin payments
if data.txid:
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(
# Format payment entry
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_in_sats,
description=data.description,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
flag=JournalEntryFlag.CLEARED, # Manual payments are immediately cleared
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=payment_account.id,
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_date=datetime.now().date(),
is_payable=False, # User paying castle (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"MANUAL-{data.user_id[:8]}"
)
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
balance = await get_user_balance(data.user_id)
# Submit to Fava
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 {
"journal_entry_id": entry.id,
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_settled": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance.balance,
"new_balance": balance_data["balance"],
"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.",
)
# Determine the amount to record in the journal
# IMPORTANT: Always record in satoshis to match the payable account balance
# Format payment entry and submit to Fava
# 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
fava = get_fava_client()
# Determine amount and currency
if data.currency:
# Fiat currency payment (e.g., EUR, USD)
# 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"
)
amount_in_sats = data.amount_sats
line_metadata = {
"fiat_currency": data.currency.upper(),
"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,
}
fiat_currency = data.currency.upper()
fiat_amount = data.amount
else:
# Satoshi payment
amount_in_sats = int(data.amount)
line_metadata = {}
fiat_currency = None
fiat_amount = None
# Add payment hash for lightning payments
if data.payment_hash:
line_metadata["payment_hash"] = data.payment_hash
# Add transaction ID for on-chain Bitcoin payments
if data.txid:
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(
# Format payment entry
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount_in_sats,
description=data.description or f"Payment to user via {data.payment_method}",
reference=data.reference or f"PAY-{data.user_id[:8]}",
flag=JournalEntryFlag.CLEARED, # Payments are immediately cleared
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=user_payable.id,
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_date=datetime.now().date(),
is_payable=True, # Castle paying user (payable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"PAY-{data.user_id[:8]}"
)
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
balance = await get_user_balance(data.user_id)
# Submit to Fava
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 {
"journal_entry_id": entry.id,
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_paid": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance.balance,
"new_balance": balance_data["balance"],
"message": f"User paid successfully via {data.payment_method}",
}