castle/views_api.py
2025-10-22 12:33:45 +02:00

411 lines
12 KiB
Python

from http import HTTPStatus
from fastapi import APIRouter, HTTPException
from lnbits.core.crud import get_user
from lnbits.decorators import require_admin_key, require_invoice_key
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,
CreateAccount,
CreateEntryLine,
CreateJournalEntry,
ExpenseEntry,
JournalEntry,
ReceivableEntry,
RevenueEntry,
UserBalance,
)
castle_api_router = APIRouter(prefix="/api/v1", tags=["castle"])
# ===== ACCOUNT ENDPOINTS =====
@castle_api_router.get("/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("/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account(
data: CreateAccount,
wallet_id: str = require_admin_key, # type: ignore
) -> Account:
"""Create a new account (admin only)"""
return await create_account(data)
@castle_api_router.get("/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("/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("/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("/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("/entries/user")
async def api_get_user_entries(
wallet_id: str = require_invoice_key, limit: int = 100 # type: ignore
) -> list[JournalEntry]:
"""Get journal entries created by the current user"""
return await get_journal_entries_by_user(wallet_id, limit)
@castle_api_router.get("/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("/entries", status_code=HTTPStatus.CREATED)
async def api_create_journal_entry(
data: CreateJournalEntry,
wallet_id: str = require_invoice_key, # type: ignore
) -> JournalEntry:
"""Create a new journal entry"""
try:
return await create_journal_entry(data, wallet_id)
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
# ===== SIMPLIFIED ENTRY ENDPOINTS =====
@castle_api_router.post("/entries/expense", status_code=HTTPStatus.CREATED)
async def api_create_expense_entry(
data: ExpenseEntry,
wallet_id: str = require_invoice_key, # type: ignore
) -> 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).
"""
# 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)
entry_data = CreateJournalEntry(
description=data.description,
reference=data.reference,
lines=[
CreateEntryLine(
account_id=expense_account.id,
debit=data.amount,
credit=0,
description=f"Expense paid by user {data.user_wallet[:8]}",
),
CreateEntryLine(
account_id=user_account.id,
debit=0,
credit=data.amount,
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
),
],
)
return await create_journal_entry(entry_data, wallet_id)
@castle_api_router.post("/entries/receivable", status_code=HTTPStatus.CREATED)
async def api_create_receivable_entry(
data: ReceivableEntry,
wallet_id: str = require_admin_key, # type: ignore
) -> 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_id)
@castle_api_router.post("/entries/revenue", status_code=HTTPStatus.CREATED)
async def api_create_revenue_entry(
data: RevenueEntry,
wallet_id: str = require_admin_key, # type: ignore
) -> 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_id)
# ===== USER BALANCE ENDPOINTS =====
@castle_api_router.get("/balance")
async def api_get_my_balance(
wallet_id: str = require_invoice_key, # type: ignore
) -> UserBalance:
"""Get current user's balance with the Castle"""
return await get_user_balance(wallet_id)
@castle_api_router.get("/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("/pay-balance")
async def api_pay_balance(
amount: int,
wallet_id: str = require_invoice_key, # type: ignore
) -> 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.
"""
# 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("/pay-user")
async def api_pay_user(
user_id: str,
amount: int,
wallet_id: str = require_admin_key, # type: ignore
) -> 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_id)
# Get updated balance
balance = await get_user_balance(user_id)
return {
"journal_entry": entry.dict(),
"new_balance": balance.balance,
"message": "Payment recorded successfully",
}