Adds functionality to pay users (Castle pays)

Implements the ability for the super user (Castle) to pay other users for expenses or liabilities.

Introduces a new `PayUser` model to represent these payments, along with API endpoints to process and record them.

Integrates a "Pay User" button into the user list, allowing the super user to initiate payments through either lightning or manual methods (cash, bank transfer, check).

Adds UI elements and logic for handling both lightning payments (generating invoices and paying them) and manual payment recording.

This functionality allows Castle to manage and settle debts with its users directly through the application.
This commit is contained in:
padreug 2025-10-23 10:01:33 +02:00
parent f0257e7c7f
commit 60aba90e00
4 changed files with 560 additions and 1 deletions

View file

@ -56,6 +56,7 @@ from .models import (
JournalEntry,
JournalEntryFlag,
ManualPaymentRequest,
PayUser,
ReceivableEntry,
RecordPayment,
RevenueEntry,
@ -898,6 +899,159 @@ async def api_settle_receivable(
}
@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", "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
if data.payment_method.lower() == "lightning":
# For lightning, use the Lightning Wallet account
payment_account = await get_account_by_name("Lightning Wallet")
if not payment_account:
# Create it if it doesn't exist
payment_account = await create_account(
CreateAccount(
name="Lightning Wallet",
account_type=AccountType.ASSET,
description="Lightning Network wallet for Castle",
),
wallet.wallet.id,
)
else:
# For cash/bank/check
payment_account_map = {
"cash": "Cash",
"bank_transfer": "Bank Account",
"check": "Bank Account",
"other": "Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
payment_account = await get_account_by_name(account_name)
if not payment_account:
# Try to find any asset account
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,
"fiat_amount": str(data.amount),
"exchange_rate": data.amount_sats / float(data.amount)
}
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
# 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 =====
@ -932,6 +1086,38 @@ async def api_update_settings(
# ===== 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),