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

@ -194,6 +194,19 @@ class SettleReceivable(BaseModel):
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
class PayUser(BaseModel):
"""Pay a user (castle pays user for expense/liability)"""
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
payment_method: str # "cash", "bank_transfer", "lightning", "check", "other"
description: Optional[str] = None # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments
class AssertionStatus(str, Enum):
"""Status of a balance assertion"""
PENDING = "pending" # Not yet checked

View file

@ -99,6 +99,22 @@ window.app = Vue.createApp({
pollIntervalId: null,
exchangeRate: 3571.43, // sats per EUR (TODO: fetch from API)
originalCurrency: 'BTC' // Track original receivable currency
},
payUserDialog: {
show: false,
user_id: '',
username: '',
maxAmount: 0,
maxAmountFiat: 0,
fiatCurrency: null,
amount: 0,
payment_method: 'lightning',
description: '',
reference: '',
loading: false,
paymentSuccess: false,
exchangeRate: 3571.43,
originalCurrency: 'BTC'
}
}
},
@ -135,6 +151,25 @@ window.app = Vue.createApp({
else if (!isOldCash && isNewCash) {
this.settleReceivableDialog.amount = this.settleReceivableDialog.maxAmountFiat || 0
}
},
'payUserDialog.payment_method': function(newVal, oldVal) {
// Convert amount when payment method changes between cash and lightning
if (!oldVal) return
const isOldCash = ['cash', 'bank_transfer', 'check'].includes(oldVal)
const isNewCash = ['cash', 'bank_transfer', 'check'].includes(newVal)
// Only convert if switching between cash and lightning
if (isOldCash === isNewCash) return
// Convert from fiat to sats (when switching from cash to lightning)
if (isOldCash && !isNewCash) {
this.payUserDialog.amount = this.payUserDialog.maxAmount
}
// Convert from sats to fiat (when switching from lightning to cash)
else if (!isOldCash && isNewCash) {
this.payUserDialog.amount = this.payUserDialog.maxAmountFiat || 0
}
}
},
computed: {
@ -180,6 +215,30 @@ window.app = Vue.createApp({
)
return isCashPayment ? '0.01' : '1'
},
paymentAmountLabel() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
if (isCashPayment && this.payUserDialog.fiatCurrency) {
return `Payment Amount (${this.payUserDialog.fiatCurrency}) *`
}
return 'Payment Amount (sats) *'
},
paymentMaxAmount() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
if (isCashPayment && this.payUserDialog.maxAmountFiat) {
return this.payUserDialog.maxAmountFiat
}
return this.payUserDialog.maxAmount
},
paymentAmountStep() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
return isCashPayment ? '0.01' : '1'
},
currencyOptions() {
const options = [{label: 'Satoshis (default)', value: null}]
this.currencies.forEach(curr => {
@ -1132,6 +1191,172 @@ window.app = Vue.createApp({
this.settleReceivableDialog.loading = false
}
},
showPayUserDialog(userBalance) {
// Only show for users castle owes (positive balance)
if (userBalance.balance <= 0) return
// Extract fiat balances (e.g., EUR)
const fiatBalances = userBalance.fiat_balances || {}
const fiatCurrency = Object.keys(fiatBalances)[0] || null
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
this.payUserDialog = {
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: userBalance.balance, // Positive sats amount castle owes
maxAmountFiat: fiatAmount, // EUR or other fiat amount
fiatCurrency: fiatCurrency,
amount: fiatCurrency ? fiatAmount : userBalance.balance, // Default to fiat if available
payment_method: 'lightning', // Default to lightning for paying
description: '',
reference: '',
loading: false,
paymentSuccess: false,
exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : 3571.43,
originalCurrency: fiatCurrency || 'BTC'
}
},
async sendLightningPayment() {
this.payUserDialog.loading = true
try {
// Request an invoice from the user's wallet
const userWallet = await this.getUserWallet(this.payUserDialog.user_id)
if (!userWallet || !userWallet.user_wallet_id) {
throw new Error('User has not configured their wallet. Ask them to set up their wallet first.')
}
// Generate invoice on user's wallet
const invoiceResponse = await LNbits.api.request(
'POST',
`/api/v1/payments`,
userWallet.user_wallet_id_invoice_key,
{
out: false,
amount: this.payUserDialog.amount,
memo: `Payment from Castle to ${this.payUserDialog.username}`
}
)
const paymentRequest = invoiceResponse.data.payment_request
// Pay the invoice from Castle's wallet
const paymentResponse = await LNbits.api.request(
'POST',
`/api/v1/payments`,
this.g.user.wallets[0].adminkey,
{
out: true,
bolt11: paymentRequest
}
)
// Record the payment in Castle accounting
await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
{
user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount,
payment_method: 'lightning',
payment_hash: paymentResponse.data.payment_hash
}
)
this.payUserDialog.paymentSuccess = true
this.$q.notify({
type: 'positive',
message: `Successfully sent ${this.formatSats(this.payUserDialog.amount)} sats to ${this.payUserDialog.username}`,
timeout: 3000
})
// Reload balances after a short delay
setTimeout(async () => {
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
this.payUserDialog.show = false
}, 2000)
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.payUserDialog.loading = false
}
},
async submitPayUser() {
this.payUserDialog.loading = true
try {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
// Create description with payment method
const paymentMethodLabels = {
'cash': 'Cash payment',
'bank_transfer': 'Bank transfer',
'check': 'Check payment',
'other': 'Payment'
}
const methodLabel = paymentMethodLabels[this.payUserDialog.payment_method] || 'Payment'
const description = this.payUserDialog.description ||
`${methodLabel} to ${this.payUserDialog.username}`
const payload = {
user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount,
payment_method: this.payUserDialog.payment_method,
description: description,
reference: this.payUserDialog.reference || null,
}
// Add currency info for fiat payments
if (isCashPayment && this.payUserDialog.fiatCurrency) {
payload.currency = this.payUserDialog.fiatCurrency
payload.amount_sats = this.payUserDialog.maxAmount
}
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
payload
)
this.$q.notify({
type: 'positive',
message: response.data.message,
timeout: 3000
})
this.payUserDialog.show = false
// Reload balances
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.payUserDialog.loading = false
}
},
async getUserWallet(userId) {
try {
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/user-wallet/${userId}`,
this.g.user.wallets[0].adminkey
)
return response.data
} catch (error) {
console.error('Error fetching user wallet:', error)
return null
}
},
formatSats(amount) {
return new Intl.NumberFormat().format(amount)
},

View file

@ -192,6 +192,7 @@
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<!-- User owes Castle (negative balance) - Castle receives payment -->
<q-btn
v-if="props.row.balance < 0"
flat
@ -201,7 +202,19 @@
icon="payments"
@click="showSettleReceivableDialog(props.row)"
>
<q-tooltip>Settle receivable (user pays)</q-tooltip>
<q-tooltip>Settle receivable (user pays castle)</q-tooltip>
</q-btn>
<!-- Castle owes User (positive balance) - Castle pays user -->
<q-btn
v-if="props.row.balance > 0"
flat
dense
size="sm"
color="positive"
icon="send"
@click="showPayUserDialog(props.row)"
>
<q-tooltip>Pay user (castle pays user)</q-tooltip>
</q-btn>
</q-td>
</template>
@ -1254,4 +1267,126 @@
</q-card>
</q-dialog>
<!-- Pay User Dialog (Castle pays user - Super User Only) -->
<q-dialog v-model="payUserDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 400px">
<q-form @submit="submitPayUser">
<h6 class="q-my-none q-mb-md">Pay User</h6>
<div class="q-mb-md">
<div class="text-subtitle2">User</div>
<div>{% raw %}{{ payUserDialog.username }}{% endraw %}</div>
<div class="text-caption text-grey">{% raw %}{{ payUserDialog.user_id }}{% endraw %}</div>
</div>
<div class="q-mb-md">
<div class="text-subtitle2">Amount Castle Owes</div>
<div class="text-positive text-h6">
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
</div>
<div v-if="payUserDialog.fiatCurrency && payUserDialog.maxAmountFiat" class="text-caption">
{% raw %}{{ formatFiat(payUserDialog.maxAmountFiat, payUserDialog.fiatCurrency) }}{% endraw %}
</div>
</div>
<q-input
filled
dense
v-model.number="payUserDialog.amount"
type="number"
:label="paymentAmountLabel"
hint="Amount castle is paying (max: owed amount)"
:max="paymentMaxAmount"
:step="paymentAmountStep"
:rules="[
val => val !== null && val !== undefined && val !== '' || 'Amount is required',
val => val > 0 || 'Amount must be positive',
val => val <= paymentMaxAmount || 'Cannot exceed owed amount'
]"
></q-input>
<q-select
filled
dense
v-model="payUserDialog.payment_method"
:options="[
{label: 'Lightning Payment', value: 'lightning'},
{label: 'Cash', value: 'cash'},
{label: 'Bank Transfer', value: 'bank_transfer'},
{label: 'Check', value: 'check'},
{label: 'Other', value: 'other'}
]"
option-label="label"
option-value="value"
emit-value
map-options
label="Payment Method *"
:rules="[val => !!val || 'Payment method is required']"
></q-select>
<q-input
v-if="payUserDialog.payment_method !== 'lightning'"
filled
dense
v-model="payUserDialog.description"
type="text"
label="Description (optional)"
:placeholder="payUserDialog.payment_method === 'cash' ?
`Cash payment to ${payUserDialog.username}` :
payUserDialog.payment_method === 'bank_transfer' ?
`Bank transfer to ${payUserDialog.username}` :
payUserDialog.payment_method === 'check' ?
`Check payment to ${payUserDialog.username}` :
`Payment to ${payUserDialog.username}`"
hint="Auto-generated if left empty"
></q-input>
<q-input
v-if="payUserDialog.payment_method !== 'lightning'"
filled
dense
v-model="payUserDialog.reference"
type="text"
label="Reference (optional)"
hint="Receipt number, transaction ID, etc."
></q-input>
<!-- Show success message if lightning payment was made -->
<div v-if="payUserDialog.paymentSuccess" class="q-mt-md q-pa-md bg-positive text-white rounded-borders">
<q-icon name="check_circle" size="md" class="q-mr-sm"></q-icon>
Payment sent successfully!
</div>
<div class="row q-mt-md q-gutter-sm">
<!-- For lightning: send payment button -->
<q-btn
v-if="payUserDialog.payment_method === 'lightning'"
unelevated
color="positive"
@click="sendLightningPayment"
:loading="payUserDialog.loading"
:disable="payUserDialog.paymentSuccess"
>
Send Lightning Payment
</q-btn>
<!-- For non-lightning: record manual payment button -->
<q-btn
v-if="payUserDialog.payment_method !== 'lightning'"
unelevated
color="positive"
type="submit"
:loading="payUserDialog.loading"
>
Record Payment
</q-btn>
<q-btn v-close-popup flat color="grey">
{% raw %}{{ payUserDialog.paymentSuccess ? 'Close' : 'Cancel' }}{% endraw %}
</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %}

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),