diff --git a/views_api.py b/views_api.py index 8389d32..b20b048 100644 --- a/views_api.py +++ b/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}", }