Allows users to configure their own wallet ID, enabling the system to track expenses and receivables on a per-user basis. Introduces new database table, models, API endpoints, and UI elements to manage user-specific wallet settings.
550 lines
17 KiB
Python
550 lines
17 KiB
Python
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 (
|
|
create_account,
|
|
create_journal_entry,
|
|
get_account,
|
|
get_account_balance,
|
|
get_account_by_name,
|
|
get_account_transactions,
|
|
get_all_accounts,
|
|
get_all_journal_entries,
|
|
get_journal_entries_by_user,
|
|
get_journal_entry,
|
|
get_or_create_user_account,
|
|
get_user_balance,
|
|
)
|
|
from .models import (
|
|
Account,
|
|
AccountType,
|
|
CastleSettings,
|
|
CreateAccount,
|
|
CreateEntryLine,
|
|
CreateJournalEntry,
|
|
ExpenseEntry,
|
|
JournalEntry,
|
|
ReceivableEntry,
|
|
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"""
|
|
settings = await get_user_wallet(user_id)
|
|
if not settings or not settings.user_wallet_id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="You must configure your wallet in settings before using this feature.",
|
|
)
|
|
return settings.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 created by the current user"""
|
|
return await get_journal_entries_by_user(wallet.wallet.id, limit)
|
|
|
|
|
|
@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(data.amount, data.currency)
|
|
|
|
# Store currency metadata
|
|
metadata = {
|
|
"fiat_currency": data.currency.upper(),
|
|
"fiat_amount": round(data.amount, ndigits=3),
|
|
"fiat_rate": amount_sats / data.amount if data.amount > 0 else 0,
|
|
"btc_rate": (data.amount / 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(
|
|
data.user_wallet, AccountType.EQUITY, "Member Equity"
|
|
)
|
|
else:
|
|
# Liability (castle owes user)
|
|
user_account = await get_or_create_user_account(
|
|
data.user_wallet, 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 ""
|
|
entry_data = CreateJournalEntry(
|
|
description=data.description + description_suffix,
|
|
reference=data.reference,
|
|
lines=[
|
|
CreateEntryLine(
|
|
account_id=expense_account.id,
|
|
debit=amount_sats,
|
|
credit=0,
|
|
description=f"Expense paid by user {data.user_wallet[: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.
|
|
"""
|
|
# 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_wallet, AccountType.ASSET, "Accounts Receivable"
|
|
)
|
|
|
|
# Create journal entry
|
|
# DR Accounts Receivable (User), CR Revenue
|
|
entry_data = CreateJournalEntry(
|
|
description=data.description,
|
|
reference=data.reference,
|
|
lines=[
|
|
CreateEntryLine(
|
|
account_id=user_receivable.id,
|
|
debit=data.amount,
|
|
credit=0,
|
|
description=f"Amount owed by user {data.user_wallet[:8]}",
|
|
),
|
|
CreateEntryLine(
|
|
account_id=revenue_account.id,
|
|
debit=0,
|
|
credit=data.amount,
|
|
description="Revenue earned",
|
|
),
|
|
],
|
|
)
|
|
|
|
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"""
|
|
return await get_user_balance(wallet.wallet.id)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# ===== PAYMENT ENDPOINTS =====
|
|
|
|
|
|
@castle_api_router.post("/api/v1/pay-balance")
|
|
async def api_pay_balance(
|
|
amount: int,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> dict:
|
|
"""
|
|
Record a payment from user to castle (reduces what user owes or what castle owes user).
|
|
This should be called after an invoice is paid.
|
|
"""
|
|
wallet_id = wallet.wallet.id
|
|
|
|
# Get user's receivable account (what user owes)
|
|
user_receivable = await get_or_create_user_account(
|
|
wallet_id, 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
|
|
# DR Lightning Balance, CR Accounts Receivable (User)
|
|
entry_data = CreateJournalEntry(
|
|
description=f"Payment received from user {wallet_id[:8]}",
|
|
lines=[
|
|
CreateEntryLine(
|
|
account_id=lightning_account.id,
|
|
debit=amount,
|
|
credit=0,
|
|
description="Lightning payment received",
|
|
),
|
|
CreateEntryLine(
|
|
account_id=user_receivable.id,
|
|
debit=0,
|
|
credit=amount,
|
|
description="Payment applied to balance",
|
|
),
|
|
],
|
|
)
|
|
|
|
entry = await create_journal_entry(entry_data, wallet_id)
|
|
|
|
# Get updated balance
|
|
balance = await get_user_balance(wallet_id)
|
|
|
|
return {
|
|
"journal_entry": entry.dict(),
|
|
"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/user/wallet")
|
|
async def api_get_user_wallet(
|
|
user: User = Depends(check_user_exists),
|
|
) -> UserWalletSettings:
|
|
"""Get current user's wallet settings"""
|
|
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"""
|
|
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)
|