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:
parent
e3acc53e20
commit
efc09aa5ce
1 changed files with 163 additions and 188 deletions
351
views_api.py
351
views_api.py
|
|
@ -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:
|
||||
# Payment already recorded, return existing entry
|
||||
balance = await get_user_balance(target_user_id)
|
||||
return {
|
||||
"journal_entry_id": existing.id,
|
||||
"new_balance": balance.balance,
|
||||
"message": "Payment already recorded",
|
||||
}
|
||||
# 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_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
|
||||
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}",
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue