Adds fiat currency information to payment invoices and ledger entries. This allows for tracking the fiat value of transactions and provides a more complete financial picture. Calculates the fiat amount proportionally based on the user's balance and includes the fiat currency, amount, and exchange rates in the invoice's extra data. This data is then extracted and added to the ledger entry's metadata when recording the payment.
1822 lines
61 KiB
Python
1822 lines
61 KiB
Python
from datetime import datetime
|
|
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,
|
|
PayUser,
|
|
ReceivableEntry,
|
|
RecordPayment,
|
|
RevenueEntry,
|
|
SettleReceivable,
|
|
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,
|
|
entry_date=data.entry_date,
|
|
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.
|
|
|
|
If user_id is provided (admin only), the invoice is generated for that specific user.
|
|
"""
|
|
from lnbits.core.crud.wallets import get_wallet
|
|
from lnbits.core.models import CreateInvoice
|
|
from lnbits.core.services import create_payment_request
|
|
from lnbits.settings import settings as lnbits_settings
|
|
|
|
# Determine which user this invoice is for
|
|
if data.user_id:
|
|
# Admin generating invoice for a specific user
|
|
if wallet.wallet.user != lnbits_settings.super_user:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="Only super user can generate invoices for other users",
|
|
)
|
|
target_user_id = data.user_id
|
|
else:
|
|
# User generating invoice for themselves
|
|
target_user_id = wallet.wallet.user
|
|
|
|
# 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)
|
|
|
|
# Calculate proportional fiat amount for this invoice
|
|
invoice_extra = {"tag": "castle", "user_id": target_user_id}
|
|
|
|
if user_balance.fiat_balances:
|
|
# Simple single-currency solution: use the first (and should be only) currency
|
|
currencies = list(user_balance.fiat_balances.keys())
|
|
|
|
if len(currencies) > 1:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
|
|
)
|
|
|
|
if len(currencies) == 1:
|
|
fiat_currency = currencies[0]
|
|
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
|
|
total_sat_balance = abs(user_balance.balance) # Use absolute value
|
|
|
|
if total_sat_balance > 0:
|
|
# Calculate proportional fiat amount for this invoice
|
|
# fiat_amount = (invoice_amount / total_sats) * total_fiat
|
|
from decimal import Decimal
|
|
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
|
|
invoice_fiat_amount = abs(total_fiat_balance) * proportion
|
|
|
|
# Calculate fiat rate (sats per fiat unit)
|
|
fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0
|
|
btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0
|
|
|
|
invoice_extra.update({
|
|
"fiat_currency": fiat_currency,
|
|
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
|
|
"fiat_rate": fiat_rate,
|
|
"btc_rate": btc_rate,
|
|
})
|
|
|
|
# Create invoice on castle wallet
|
|
invoice_data = CreateInvoice(
|
|
out=False,
|
|
amount=data.amount,
|
|
memo=f"Payment from user {target_user_id[:8]} to Castle",
|
|
unit="sat",
|
|
extra=invoice_extra,
|
|
)
|
|
|
|
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.
|
|
|
|
The user_id is extracted from the payment metadata (set during invoice generation).
|
|
"""
|
|
from lnbits.core.crud.payments import get_standalone_payment
|
|
|
|
# Get the payment details (incoming=True to get the invoice, not the payment)
|
|
payment = await get_standalone_payment(data.payment_hash, incoming=True)
|
|
if not payment:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment not found"
|
|
)
|
|
|
|
if payment.pending:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet settled"
|
|
)
|
|
|
|
# Get user_id from payment metadata (set during invoice generation)
|
|
target_user_id = None
|
|
if payment.extra and isinstance(payment.extra, dict):
|
|
target_user_id = payment.extra.get("user_id")
|
|
|
|
if not target_user_id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
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",
|
|
}
|
|
|
|
# Convert amount from millisatoshis to satoshis
|
|
amount_sats = payment.amount // 1000
|
|
|
|
# Extract fiat metadata from invoice (if present)
|
|
line_metadata = {}
|
|
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,
|
|
}
|
|
|
|
# Get user's receivable account (what user owes)
|
|
user_receivable = await get_or_create_user_account(
|
|
target_user_id, AccountType.ASSET, "Accounts Receivable"
|
|
)
|
|
|
|
# Get lightning account
|
|
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
|
if not lightning_account:
|
|
raise HTTPException(
|
|
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(
|
|
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,
|
|
debit=amount_sats,
|
|
credit=0,
|
|
description="Lightning payment received",
|
|
metadata=line_metadata,
|
|
),
|
|
CreateEntryLine(
|
|
account_id=user_receivable.id,
|
|
debit=0,
|
|
credit=amount_sats,
|
|
description="Payment applied to balance",
|
|
metadata=line_metadata,
|
|
),
|
|
],
|
|
)
|
|
|
|
entry = await create_journal_entry(entry_data, target_user_id)
|
|
|
|
# Get updated balance
|
|
balance = await get_user_balance(target_user_id)
|
|
|
|
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("Assets:Bitcoin:Lightning")
|
|
if not lightning_account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
|
|
)
|
|
|
|
# Create journal entry
|
|
# DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning
|
|
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",
|
|
}
|
|
|
|
|
|
@castle_api_router.post("/api/v1/receivables/settle")
|
|
async def api_settle_receivable(
|
|
data: SettleReceivable,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""
|
|
Manually settle a receivable (record when user pays castle in person).
|
|
|
|
This endpoint is for non-lightning payments like:
|
|
- Cash payments
|
|
- Bank transfers
|
|
- Other manual settlements
|
|
|
|
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 settle receivables",
|
|
)
|
|
|
|
# Validate payment method
|
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
|
if data.payment_method.lower() not in valid_methods:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
|
|
)
|
|
|
|
# Get user's receivable account (what user owes)
|
|
user_receivable = await get_or_create_user_account(
|
|
data.user_id, AccountType.ASSET, "Accounts Receivable"
|
|
)
|
|
|
|
# Get the appropriate asset account based on payment method
|
|
payment_account_map = {
|
|
"cash": "Assets:Cash",
|
|
"bank_transfer": "Assets:Bank",
|
|
"check": "Assets:Bank",
|
|
"lightning": "Assets:Bitcoin:Lightning",
|
|
"btc_onchain": "Assets:Bitcoin:OnChain",
|
|
"other": "Assets:Cash"
|
|
}
|
|
|
|
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
|
|
payment_account = await get_account_by_name(account_name)
|
|
|
|
# If account doesn't exist, try to find or create a generic one
|
|
if not payment_account:
|
|
# Try to find any asset account that's not receivable
|
|
all_accounts = await get_all_accounts()
|
|
for acc in all_accounts:
|
|
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
|
|
payment_account = acc
|
|
break
|
|
|
|
if not payment_account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail=f"Payment account '{account_name}' not found. Please create it first.",
|
|
)
|
|
|
|
# Create journal entry
|
|
# 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 decimal import Decimal
|
|
|
|
if data.currency:
|
|
# Fiat currency payment (e.g., EUR, USD)
|
|
# Use the sats equivalent for the journal entry to match the receivable
|
|
if not data.amount_sats:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
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,
|
|
}
|
|
else:
|
|
# Satoshi payment
|
|
amount_in_sats = int(data.amount)
|
|
line_metadata = {}
|
|
|
|
# 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(
|
|
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,
|
|
debit=amount_in_sats,
|
|
credit=0,
|
|
description=f"Payment received via {data.payment_method}",
|
|
metadata=line_metadata,
|
|
),
|
|
CreateEntryLine(
|
|
account_id=user_receivable.id,
|
|
debit=0,
|
|
credit=amount_in_sats,
|
|
description="Receivable settled",
|
|
metadata=line_metadata,
|
|
),
|
|
],
|
|
)
|
|
|
|
entry = await create_journal_entry(entry_data, wallet.wallet.id)
|
|
|
|
# Get updated balance
|
|
balance = await get_user_balance(data.user_id)
|
|
|
|
return {
|
|
"journal_entry_id": entry.id,
|
|
"user_id": data.user_id,
|
|
"amount_settled": float(data.amount),
|
|
"currency": data.currency,
|
|
"payment_method": data.payment_method,
|
|
"new_balance": balance.balance,
|
|
"message": f"Receivable settled successfully via {data.payment_method}",
|
|
}
|
|
|
|
|
|
@castle_api_router.post("/api/v1/payables/pay")
|
|
async def api_pay_user(
|
|
data: PayUser,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""
|
|
Pay a user (castle pays user for expense/liability).
|
|
|
|
This endpoint is for both lightning and manual payments:
|
|
- Lightning payments: already executed, just record the payment
|
|
- Cash/Bank/Check: record manual payment that was made
|
|
|
|
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 pay users",
|
|
)
|
|
|
|
# Validate payment method
|
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
|
if data.payment_method.lower() not in valid_methods:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
|
|
)
|
|
|
|
# Get user's payable account (what castle owes)
|
|
user_payable = await get_or_create_user_account(
|
|
data.user_id, AccountType.LIABILITY, "Accounts Payable"
|
|
)
|
|
|
|
# Get the appropriate asset account based on payment method
|
|
payment_account_map = {
|
|
"cash": "Assets:Cash",
|
|
"bank_transfer": "Assets:Bank",
|
|
"check": "Assets:Bank",
|
|
"lightning": "Assets:Bitcoin:Lightning",
|
|
"btc_onchain": "Assets:Bitcoin:OnChain",
|
|
"other": "Assets:Cash"
|
|
}
|
|
|
|
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
|
|
payment_account = await get_account_by_name(account_name)
|
|
|
|
if not payment_account:
|
|
# Try to find any asset account that's not receivable
|
|
all_accounts = await get_all_accounts()
|
|
for acc in all_accounts:
|
|
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
|
|
payment_account = acc
|
|
break
|
|
|
|
if not payment_account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
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
|
|
from decimal import Decimal
|
|
|
|
if data.currency:
|
|
# Fiat currency payment (e.g., EUR, USD)
|
|
# Use the sats equivalent for the journal entry to match the payable
|
|
if not data.amount_sats:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
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,
|
|
}
|
|
else:
|
|
# Satoshi payment
|
|
amount_in_sats = int(data.amount)
|
|
line_metadata = {}
|
|
|
|
# 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(
|
|
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,
|
|
debit=amount_in_sats,
|
|
credit=0,
|
|
description="Payable settled",
|
|
metadata=line_metadata,
|
|
),
|
|
CreateEntryLine(
|
|
account_id=payment_account.id,
|
|
debit=0,
|
|
credit=amount_in_sats,
|
|
description=f"Payment sent via {data.payment_method}",
|
|
metadata=line_metadata,
|
|
),
|
|
],
|
|
)
|
|
|
|
entry = await create_journal_entry(entry_data, wallet.wallet.id)
|
|
|
|
# Get updated balance
|
|
balance = await get_user_balance(data.user_id)
|
|
|
|
return {
|
|
"journal_entry_id": entry.id,
|
|
"user_id": data.user_id,
|
|
"amount_paid": float(data.amount),
|
|
"currency": data.currency,
|
|
"payment_method": data.payment_method,
|
|
"new_balance": balance.balance,
|
|
"message": f"User paid successfully via {data.payment_method}",
|
|
}
|
|
|
|
|
|
# ===== 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/user-wallet/{user_id}")
|
|
async def api_get_user_wallet(
|
|
user_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Get user's wallet settings (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 user wallet info",
|
|
)
|
|
|
|
user_wallet = await get_user_wallet(user_id)
|
|
if not user_wallet:
|
|
return {"user_id": user_id, "user_wallet_id": None}
|
|
|
|
# Get invoice key for the user's wallet (needed to generate invoices)
|
|
from lnbits.core.crud import get_wallet
|
|
|
|
wallet_obj = await get_wallet(user_wallet.user_wallet_id)
|
|
if not wallet_obj:
|
|
return {"user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id}
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"user_wallet_id": user_wallet.user_wallet_id,
|
|
"user_wallet_id_invoice_key": wallet_obj.inkey,
|
|
}
|
|
|
|
|
|
@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("Assets:Bitcoin:Lightning")
|
|
if not lightning_account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail="Lightning 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"}
|
|
|
|
|
|
# ===== RECONCILIATION ENDPOINTS =====
|
|
|
|
|
|
@castle_api_router.get("/api/v1/reconciliation/summary")
|
|
async def api_get_reconciliation_summary(
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Get reconciliation summary (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 reconciliation",
|
|
)
|
|
|
|
# Get all assertions
|
|
all_assertions = await get_balance_assertions(limit=1000)
|
|
|
|
# Count by status
|
|
passed = len([a for a in all_assertions if a.status == AssertionStatus.PASSED])
|
|
failed = len([a for a in all_assertions if a.status == AssertionStatus.FAILED])
|
|
pending = len([a for a in all_assertions if a.status == AssertionStatus.PENDING])
|
|
|
|
# Get all journal entries
|
|
all_entries = await get_all_journal_entries(limit=1000)
|
|
|
|
# Count entries by flag
|
|
cleared = len([e for e in all_entries if e.flag == JournalEntryFlag.CLEARED])
|
|
pending_entries = len([e for e in all_entries if e.flag == JournalEntryFlag.PENDING])
|
|
flagged = len([e for e in all_entries if e.flag == JournalEntryFlag.FLAGGED])
|
|
voided = len([e for e in all_entries if e.flag == JournalEntryFlag.VOID])
|
|
|
|
# Get all accounts
|
|
accounts = await get_all_accounts()
|
|
|
|
return {
|
|
"assertions": {
|
|
"total": len(all_assertions),
|
|
"passed": passed,
|
|
"failed": failed,
|
|
"pending": pending,
|
|
},
|
|
"entries": {
|
|
"total": len(all_entries),
|
|
"cleared": cleared,
|
|
"pending": pending_entries,
|
|
"flagged": flagged,
|
|
"voided": voided,
|
|
},
|
|
"accounts": {
|
|
"total": len(accounts),
|
|
},
|
|
"last_checked": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
@castle_api_router.post("/api/v1/reconciliation/check-all")
|
|
async def api_check_all_assertions(
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Re-check all balance assertions (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 run reconciliation checks",
|
|
)
|
|
|
|
# Get all assertions
|
|
all_assertions = await get_balance_assertions(limit=1000)
|
|
|
|
results = {
|
|
"total": len(all_assertions),
|
|
"checked": 0,
|
|
"passed": 0,
|
|
"failed": 0,
|
|
"errors": 0,
|
|
}
|
|
|
|
for assertion in all_assertions:
|
|
try:
|
|
checked = await check_balance_assertion(assertion.id)
|
|
results["checked"] += 1
|
|
if checked.status == AssertionStatus.PASSED:
|
|
results["passed"] += 1
|
|
elif checked.status == AssertionStatus.FAILED:
|
|
results["failed"] += 1
|
|
except Exception as e:
|
|
results["errors"] += 1
|
|
|
|
return results
|
|
|
|
|
|
@castle_api_router.get("/api/v1/reconciliation/discrepancies")
|
|
async def api_get_discrepancies(
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Get all discrepancies (failed assertions, flagged entries) (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 discrepancies",
|
|
)
|
|
|
|
# Get failed assertions
|
|
failed_assertions = await get_balance_assertions(
|
|
status=AssertionStatus.FAILED,
|
|
limit=1000,
|
|
)
|
|
|
|
# Get flagged entries
|
|
all_entries = await get_all_journal_entries(limit=1000)
|
|
flagged_entries = [e for e in all_entries if e.flag == JournalEntryFlag.FLAGGED]
|
|
pending_entries = [e for e in all_entries if e.flag == JournalEntryFlag.PENDING]
|
|
|
|
return {
|
|
"failed_assertions": failed_assertions,
|
|
"flagged_entries": flagged_entries,
|
|
"pending_entries": pending_entries,
|
|
"total_discrepancies": len(failed_assertions) + len(flagged_entries),
|
|
}
|
|
|
|
|
|
# ===== AUTOMATED TASKS ENDPOINTS =====
|
|
|
|
|
|
@castle_api_router.post("/api/v1/tasks/daily-reconciliation")
|
|
async def api_run_daily_reconciliation(
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""
|
|
Manually trigger the daily reconciliation check (admin only).
|
|
This endpoint can also be called via cron job.
|
|
|
|
Returns a summary of the reconciliation check results.
|
|
"""
|
|
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 run daily reconciliation",
|
|
)
|
|
|
|
from .tasks import check_all_balance_assertions
|
|
|
|
try:
|
|
results = await check_all_balance_assertions()
|
|
return results
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=f"Error running daily reconciliation: {str(e)}",
|
|
)
|