castle/views_api.py
padreug 0257b7807c PHASE 2: Implements balance assertions for reconciliation
Adds balance assertion functionality to enable admins to verify accounting accuracy.

This includes:
- A new `balance_assertions` table in the database
- CRUD operations for balance assertions (create, get, list, check, delete)
- API endpoints for managing balance assertions (admin only)
- UI elements for creating, viewing, and re-checking assertions

Also, reorders the implementation roadmap in the documentation to reflect better the dependencies between phases.
2025-10-23 02:06:22 +02:00

1230 lines
40 KiB
Python

from decimal import Decimal
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import (
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from .crud import (
approve_manual_payment_request,
check_balance_assertion,
create_account,
create_balance_assertion,
create_journal_entry,
create_manual_payment_request,
db,
delete_balance_assertion,
get_account,
get_account_balance,
get_account_by_name,
get_account_transactions,
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,
get_journal_entries_by_user,
get_journal_entry,
get_manual_payment_request,
get_or_create_user_account,
get_user_balance,
get_user_manual_payment_requests,
reject_manual_payment_request,
)
from .models import (
Account,
AccountType,
AssertionStatus,
BalanceAssertion,
CastleSettings,
CreateAccount,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
CreateManualPaymentRequest,
ExpenseEntry,
GeneratePaymentInvoice,
JournalEntry,
JournalEntryFlag,
ManualPaymentRequest,
ReceivableEntry,
RecordPayment,
RevenueEntry,
UserBalance,
UserWalletSettings,
)
from .services import get_settings, get_user_wallet, update_settings, update_user_wallet
castle_api_router = APIRouter()
# ===== HELPER FUNCTIONS =====
async def check_castle_wallet_configured() -> str:
"""Ensure castle wallet is configured, return wallet_id"""
settings = await get_settings("admin")
if not settings or not settings.castle_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Castle wallet not configured. Please contact the super user to configure the Castle wallet in settings.",
)
return settings.castle_wallet_id
async def check_user_wallet_configured(user_id: str) -> str:
"""Ensure user has configured their wallet, return wallet_id"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, use the castle wallet
if user_id == lnbits_settings.super_user:
castle_settings = await get_settings("admin")
if castle_settings and castle_settings.castle_wallet_id:
return castle_settings.castle_wallet_id
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Castle wallet not configured. Please configure the Castle wallet in settings.",
)
# For regular users, check their personal wallet
user_wallet = await get_user_wallet(user_id)
if not user_wallet or not user_wallet.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You must configure your wallet in settings before using this feature.",
)
return user_wallet.user_wallet_id
# ===== UTILITY ENDPOINTS =====
@castle_api_router.get("/api/v1/currencies")
async def api_get_currencies() -> list[str]:
"""Get list of allowed currencies for fiat conversion"""
return allowed_currencies()
# ===== ACCOUNT ENDPOINTS =====
@castle_api_router.get("/api/v1/accounts")
async def api_get_accounts() -> list[Account]:
"""Get all accounts in the chart of accounts"""
return await get_all_accounts()
@castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account(
data: CreateAccount,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Account:
"""Create a new account (admin only)"""
return await create_account(data)
@castle_api_router.get("/api/v1/accounts/{account_id}")
async def api_get_account(account_id: str) -> Account:
"""Get a specific account"""
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
)
return account
@castle_api_router.get("/api/v1/accounts/{account_id}/balance")
async def api_get_account_balance(account_id: str) -> dict:
"""Get account balance"""
balance = await get_account_balance(account_id)
return {"account_id": account_id, "balance": balance}
@castle_api_router.get("/api/v1/accounts/{account_id}/transactions")
async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]:
"""Get all transactions for an account"""
transactions = await get_account_transactions(account_id, limit)
return [
{
"journal_entry": entry.dict(),
"entry_line": line.dict(),
}
for entry, line in transactions
]
# ===== JOURNAL ENTRY ENDPOINTS =====
@castle_api_router.get("/api/v1/entries")
async def api_get_journal_entries(limit: int = 100) -> list[JournalEntry]:
"""Get all journal entries"""
return await get_all_journal_entries(limit)
@castle_api_router.get("/api/v1/entries/user")
async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key),
limit: int = 100,
) -> list[JournalEntry]:
"""Get journal entries that affect the current user's accounts"""
from lnbits.settings import settings as lnbits_settings
# If super user, show all journal entries
if wallet.wallet.user == lnbits_settings.super_user:
return await get_all_journal_entries(limit)
return await get_journal_entries_by_user(wallet.wallet.user, limit)
@castle_api_router.get("/api/v1/entries/pending")
async def api_get_pending_entries(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[JournalEntry]:
"""Get all pending expense entries that need approval (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
# Get all journal entries and filter for pending flag
all_entries = await get_all_journal_entries(limit=1000)
pending_entries = [e for e in all_entries if e.flag == JournalEntryFlag.PENDING]
return pending_entries
@castle_api_router.get("/api/v1/entries/{entry_id}")
async def api_get_journal_entry(entry_id: str) -> JournalEntry:
"""Get a specific journal entry"""
entry = await get_journal_entry(entry_id)
if not entry:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Journal entry not found"
)
return entry
@castle_api_router.post("/api/v1/entries", status_code=HTTPStatus.CREATED)
async def api_create_journal_entry(
data: CreateJournalEntry,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> JournalEntry:
"""Create a new journal entry"""
try:
return await create_journal_entry(data, wallet.wallet.id)
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
# ===== SIMPLIFIED ENTRY ENDPOINTS =====
@castle_api_router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED)
async def api_create_expense_entry(
data: ExpenseEntry,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> JournalEntry:
"""
Create an expense entry for a user.
If is_equity=True, records as equity contribution.
If is_equity=False, records as liability (castle owes user).
If currency is provided, amount is converted from fiat to satoshis.
"""
# Check that castle wallet is configured
await check_castle_wallet_configured()
# Check that user has configured their wallet
await check_user_wallet_configured(wallet.wallet.user)
# Handle currency conversion
amount_sats = int(data.amount)
metadata = {}
if data.currency:
# Validate currency
if data.currency.upper() not in allowed_currencies():
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}",
)
# Convert fiat to satoshis
amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency)
# Store currency metadata (store fiat_amount as string to preserve Decimal precision)
metadata = {
"fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
}
# Get or create expense account
expense_account = await get_account_by_name(data.expense_account)
if not expense_account:
# Try to get it by ID
expense_account = await get_account(data.expense_account)
if not expense_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Expense account '{data.expense_account}' not found",
)
# Get or create user-specific account
if data.is_equity:
# Equity contribution
user_account = await get_or_create_user_account(
wallet.wallet.user, AccountType.EQUITY, "Member Equity"
)
else:
# Liability (castle owes user)
user_account = await get_or_create_user_account(
wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable"
)
# Create journal entry
# DR Expense, CR User Account (Liability or Equity)
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
# Add meta information for audit trail
entry_meta = {
"source": "api",
"created_via": "expense_entry",
"user_id": wallet.wallet.user,
"is_equity": data.is_equity,
}
entry_data = CreateJournalEntry(
description=data.description + description_suffix,
reference=data.reference,
flag=JournalEntryFlag.PENDING, # Expenses require admin approval
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=expense_account.id,
debit=amount_sats,
credit=0,
description=f"Expense paid by user {wallet.wallet.user[:8]}",
metadata=metadata,
),
CreateEntryLine(
account_id=user_account.id,
debit=0,
credit=amount_sats,
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
metadata=metadata,
),
],
)
return await create_journal_entry(entry_data, wallet.wallet.id)
@castle_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
async def api_create_receivable_entry(
data: ReceivableEntry,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""
Create an accounts receivable entry (user owes castle).
Admin only to prevent abuse.
If currency is provided, amount is converted from fiat to satoshis.
"""
# Handle currency conversion
amount_sats = int(data.amount)
metadata = {}
if data.currency:
# Validate currency
if data.currency.upper() not in allowed_currencies():
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}",
)
# Convert fiat to satoshis
amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency)
# Store currency metadata (store fiat_amount as string to preserve Decimal precision)
metadata = {
"fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
}
# Get or create revenue account
revenue_account = await get_account_by_name(data.revenue_account)
if not revenue_account:
revenue_account = await get_account(data.revenue_account)
if not revenue_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Revenue account '{data.revenue_account}' not found",
)
# Get or create user-specific receivable account
user_receivable = await get_or_create_user_account(
data.user_id, AccountType.ASSET, "Accounts Receivable"
)
# Create journal entry
# DR Accounts Receivable (User), CR Revenue
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
# Add meta information for audit trail
entry_meta = {
"source": "api",
"created_via": "receivable_entry",
"debtor_user_id": data.user_id,
}
entry_data = CreateJournalEntry(
description=data.description + description_suffix,
reference=data.reference,
flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=user_receivable.id,
debit=amount_sats,
credit=0,
description=f"Amount owed by user {data.user_id[:8]}",
metadata=metadata,
),
CreateEntryLine(
account_id=revenue_account.id,
debit=0,
credit=amount_sats,
description="Revenue earned",
metadata=metadata,
),
],
)
return await create_journal_entry(entry_data, wallet.wallet.id)
@castle_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
async def api_create_revenue_entry(
data: RevenueEntry,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""
Create a revenue entry (castle receives payment).
Admin only.
"""
# Get revenue account
revenue_account = await get_account_by_name(data.revenue_account)
if not revenue_account:
revenue_account = await get_account(data.revenue_account)
if not revenue_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Revenue account '{data.revenue_account}' not found",
)
# Get payment method account
payment_account = await get_account_by_name(data.payment_method_account)
if not payment_account:
payment_account = await get_account(data.payment_method_account)
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{data.payment_method_account}' not found",
)
# Create journal entry
# DR Cash/Lightning/Bank, CR Revenue
entry_data = CreateJournalEntry(
description=data.description,
reference=data.reference,
lines=[
CreateEntryLine(
account_id=payment_account.id,
debit=data.amount,
credit=0,
description="Payment received",
),
CreateEntryLine(
account_id=revenue_account.id,
debit=0,
credit=data.amount,
description="Revenue earned",
),
],
)
return await create_journal_entry(entry_data, wallet.wallet.id)
# ===== USER BALANCE ENDPOINTS =====
@castle_api_router.get("/api/v1/balance")
async def api_get_my_balance(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserBalance:
"""Get current user's balance with the Castle"""
from lnbits.settings import settings as lnbits_settings
# If super user, show total castle position
if wallet.wallet.user == lnbits_settings.super_user:
all_balances = await get_all_user_balances()
# Calculate total:
# Positive balances = Castle owes users (liabilities)
# Negative balances = Users owe Castle (receivables)
# Net: positive means castle owes, negative means castle is owed
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0)
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0)
net_balance = total_liabilities - total_receivables
# Aggregate fiat balances from all users
total_fiat_balances = {}
for user_balance in all_balances:
for currency, amount in user_balance.fiat_balances.items():
if currency not in total_fiat_balances:
total_fiat_balances[currency] = Decimal("0")
# Add all balances (positive and negative)
total_fiat_balances[currency] += amount
# Return net position
return UserBalance(
user_id=wallet.wallet.user,
balance=net_balance,
accounts=[],
fiat_balances=total_fiat_balances,
)
# For regular users, show their individual balance
return await get_user_balance(wallet.wallet.user)
@castle_api_router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(user_id: str) -> UserBalance:
"""Get a specific user's balance with the Castle"""
return await get_user_balance(user_id)
@castle_api_router.get("/api/v1/balances/all")
async def api_get_all_balances(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[dict]:
"""Get all user balances (admin/super user only)"""
from lnbits.core.crud.users import get_user
balances = await get_all_user_balances()
# Enrich with username information
result = []
for balance in balances:
user = await get_user(balance.user_id)
username = user.username if user and user.username else balance.user_id[:16] + "..."
result.append({
"user_id": balance.user_id,
"username": username,
"balance": balance.balance,
"fiat_balances": balance.fiat_balances,
"accounts": [acc.dict() for acc in balance.accounts],
})
return result
# ===== PAYMENT ENDPOINTS =====
@castle_api_router.post("/api/v1/generate-payment-invoice")
async def api_generate_payment_invoice(
data: GeneratePaymentInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Generate an invoice on the Castle wallet for user to pay their balance.
User can then pay this invoice to settle their debt.
"""
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import CreateInvoice
from lnbits.core.services import create_payment_request
# Get castle wallet ID
castle_wallet_id = await check_castle_wallet_configured()
# Create invoice on castle wallet
invoice_data = CreateInvoice(
out=False,
amount=data.amount,
memo=f"Payment from user {wallet.wallet.user[:8]} to Castle",
unit="sat",
extra={"user_id": wallet.wallet.user, "type": "castle_payment"},
)
payment = await create_payment_request(castle_wallet_id, invoice_data)
# Get castle wallet to return its inkey for payment checking
castle_wallet = await get_wallet(castle_wallet_id)
if not castle_wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Castle wallet not found"
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": data.amount,
"memo": invoice_data.memo,
"check_wallet_key": castle_wallet.inkey, # Key to check payment status
}
@castle_api_router.post("/api/v1/record-payment")
async def api_record_payment(
data: RecordPayment,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Record a lightning payment in accounting after invoice is paid.
This reduces what the user owes to the castle.
"""
from lnbits.core.crud.payments import get_standalone_payment
# Get the payment details
payment = await get_standalone_payment(data.payment_hash)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment not found"
)
if not payment.paid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet paid"
)
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
wallet.wallet.user, AccountType.ASSET, "Accounts Receivable"
)
# Get lightning account
lightning_account = await get_account_by_name("Lightning Balance")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Create journal entry to record payment
# DR Lightning Balance, CR Accounts 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": wallet.wallet.user,
}
entry_data = CreateJournalEntry(
description=f"Lightning payment from user {wallet.wallet.user[:8]}",
reference=data.payment_hash,
flag=JournalEntryFlag.CLEARED, # Payment is immediately cleared
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=lightning_account.id,
debit=payment.amount,
credit=0,
description="Lightning payment received",
),
CreateEntryLine(
account_id=user_receivable.id,
debit=0,
credit=payment.amount,
description="Payment applied to balance",
),
],
)
entry = await create_journal_entry(entry_data, wallet.wallet.user)
# Get updated balance
balance = await get_user_balance(wallet.wallet.user)
return {
"journal_entry_id": entry.id,
"new_balance": balance.balance,
"message": "Payment recorded successfully",
}
@castle_api_router.post("/api/v1/pay-user")
async def api_pay_user(
user_id: str,
amount: int,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Record a payment from castle to user (reduces what castle owes user).
Admin only.
"""
# Get user's payable account (what castle owes)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get lightning account
lightning_account = await get_account_by_name("Lightning Balance")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Create journal entry
# DR Accounts Payable (User), CR Lightning Balance
entry_data = CreateJournalEntry(
description=f"Payment to user {user_id[:8]}",
lines=[
CreateEntryLine(
account_id=user_payable.id,
debit=amount,
credit=0,
description="Payment made to user",
),
CreateEntryLine(
account_id=lightning_account.id,
debit=0,
credit=amount,
description="Lightning payment sent",
),
],
)
entry = await create_journal_entry(entry_data, wallet.wallet.id)
# Get updated balance
balance = await get_user_balance(user_id)
return {
"journal_entry": entry.dict(),
"new_balance": balance.balance,
"message": "Payment recorded successfully",
}
# ===== SETTINGS ENDPOINTS =====
@castle_api_router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_user_exists),
) -> CastleSettings:
"""Get Castle settings"""
user_id = "admin"
settings = await get_settings(user_id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return CastleSettings()
return settings
@castle_api_router.put("/api/v1/settings")
async def api_update_settings(
data: CastleSettings,
user: User = Depends(check_super_user),
) -> CastleSettings:
"""Update Castle settings (super user only)"""
if not data.castle_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Castle wallet ID is required",
)
user_id = "admin"
return await update_settings(user_id, data)
# ===== USER WALLET ENDPOINTS =====
@castle_api_router.get("/api/v1/users")
async def api_get_all_users(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[dict]:
"""Get all users who have configured their wallet (admin only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise truncate user_id
username = user.username if user and user.username else setting.id[:16] + "..."
users.append({
"user_id": setting.id,
"user_wallet_id": setting.user_wallet_id,
"username": username,
})
return users
@castle_api_router.get("/api/v1/user/wallet")
async def api_get_user_wallet(
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Get current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, return the castle wallet
if user.id == lnbits_settings.super_user:
castle_settings = await get_settings("admin")
if castle_settings and castle_settings.castle_wallet_id:
return UserWalletSettings(user_wallet_id=castle_settings.castle_wallet_id)
return UserWalletSettings()
# For regular users, get their personal wallet
settings = await get_user_wallet(user.id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return UserWalletSettings()
return settings
@castle_api_router.put("/api/v1/user/wallet")
async def api_update_user_wallet(
data: UserWalletSettings,
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Update current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# Super user cannot set their wallet separately - it's always the castle wallet
if user.id == lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Super user wallet is automatically set to the Castle wallet. Update Castle settings instead.",
)
if not data.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="User wallet ID is required",
)
return await update_user_wallet(user.id, data)
# ===== MANUAL PAYMENT REQUESTS =====
@castle_api_router.post("/api/v1/manual-payment-request")
async def api_create_manual_payment_request(
data: CreateManualPaymentRequest,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> ManualPaymentRequest:
"""Create a manual payment request for the Castle to review"""
return await create_manual_payment_request(
wallet.wallet.user, data.amount, data.description
)
@castle_api_router.get("/api/v1/manual-payment-requests")
async def api_get_manual_payment_requests(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[ManualPaymentRequest]:
"""Get manual payment requests for the current user"""
return await get_user_manual_payment_requests(wallet.wallet.user)
@castle_api_router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
return await get_all_manual_payment_requests(status)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
# Get castle wallet from settings
castle_wallet_id = await check_castle_wallet_configured()
# Get or create liability account for user (castle owes the user)
liability_account = await get_or_create_user_account(
request.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the Lightning asset account
lightning_account = await get_account_by_name("Lightning Balance")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Lightning Balance account not found",
)
# Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased)
# This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes
journal_entry = await create_journal_entry(
CreateJournalEntry(
description=f"Manual payment to user: {request.description}",
reference=f"MPR-{request.id}",
lines=[
CreateEntryLine(
account_id=liability_account.id,
debit=request.amount, # Decrease liability (castle owes less)
credit=0,
description="Payment to user",
),
CreateEntryLine(
account_id=lightning_account.id,
debit=0,
credit=request.amount, # Decrease asset (lightning balance reduced)
description="Payment from castle",
),
],
),
castle_wallet_id,
)
# Approve the request
return await approve_manual_payment_request(
request_id, wallet.wallet.user, journal_entry.id
)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ManualPaymentRequest:
"""Reject a manual payment request (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
return await reject_manual_payment_request(request_id, wallet.wallet.user)
# ===== EXPENSE APPROVAL ENDPOINTS =====
@castle_api_router.post("/api/v1/entries/{entry_id}/approve")
async def api_approve_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""Approve a pending expense entry (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can approve expenses",
)
# Get the entry
entry = await get_journal_entry(entry_id)
if not entry:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Journal entry not found",
)
if entry.flag != JournalEntryFlag.PENDING:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Entry is not pending (current status: {entry.flag.value})",
)
# Update flag to cleared
await db.execute(
"""
UPDATE journal_entries
SET flag = :flag
WHERE id = :id
""",
{"flag": JournalEntryFlag.CLEARED.value, "id": entry_id}
)
# Return updated entry
return await get_journal_entry(entry_id)
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
async def api_reject_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> JournalEntry:
"""Reject a pending expense entry (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can reject expenses",
)
# Get the entry
entry = await get_journal_entry(entry_id)
if not entry:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Journal entry not found",
)
if entry.flag != JournalEntryFlag.PENDING:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Entry is not pending (current status: {entry.flag.value})",
)
# Update flag to voided
await db.execute(
"""
UPDATE journal_entries
SET flag = :flag
WHERE id = :id
""",
{"flag": JournalEntryFlag.VOID.value, "id": entry_id}
)
# Return updated entry
return await get_journal_entry(entry_id)
# ===== BALANCE ASSERTION ENDPOINTS =====
@castle_api_router.post("/api/v1/assertions")
async def api_create_balance_assertion(
data: CreateBalanceAssertion,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> BalanceAssertion:
"""
Create a balance assertion for reconciliation (admin only).
The assertion will be checked immediately upon creation.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can create balance assertions",
)
# Verify account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account {data.account_id} not found",
)
# Create the assertion
assertion = await create_balance_assertion(data, wallet.wallet.user)
# Check it immediately
try:
assertion = await check_balance_assertion(assertion.id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(e),
)
# If assertion failed, return 409 Conflict with details
if assertion.status == AssertionStatus.FAILED:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail={
"message": "Balance assertion failed",
"expected_sats": assertion.expected_balance_sats,
"actual_sats": assertion.checked_balance_sats,
"difference_sats": assertion.difference_sats,
"expected_fiat": float(assertion.expected_balance_fiat) if assertion.expected_balance_fiat else None,
"actual_fiat": float(assertion.checked_balance_fiat) if assertion.checked_balance_fiat else None,
"difference_fiat": float(assertion.difference_fiat) if assertion.difference_fiat else None,
"fiat_currency": assertion.fiat_currency,
},
)
return assertion
@castle_api_router.get("/api/v1/assertions")
async def api_get_balance_assertions(
account_id: str = None,
status: str = None,
limit: int = 100,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can view balance assertions",
)
# Parse status enum if provided
status_enum = None
if status:
try:
status_enum = AssertionStatus(status)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid status: {status}. Must be one of: pending, passed, failed",
)
return await get_balance_assertions(
account_id=account_id,
status=status_enum,
limit=limit,
)
@castle_api_router.get("/api/v1/assertions/{assertion_id}")
async def api_get_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> BalanceAssertion:
"""Get a specific balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can view balance assertions",
)
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
return assertion
@castle_api_router.post("/api/v1/assertions/{assertion_id}/check")
async def api_check_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> BalanceAssertion:
"""Re-check a balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can check balance assertions",
)
try:
assertion = await check_balance_assertion(assertion_id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=str(e),
)
return assertion
@castle_api_router.delete("/api/v1/assertions/{assertion_id}")
async def api_delete_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Delete a balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can delete balance assertions",
)
# Verify it exists
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
await delete_balance_assertion(assertion_id)
return {"success": True, "message": "Balance assertion deleted"}