diff --git a/models.py b/models.py index 2d6b861..9dfe98f 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/static/js/index.js b/static/js/index.js index af998a4..16311c8 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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) }, diff --git a/templates/castle/index.html b/templates/castle/index.html index 25bef59..a83bcb1 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -192,6 +192,7 @@ @@ -1254,4 +1267,126 @@ + + + + +
Pay User
+ +
+
User
+
{% raw %}{{ payUserDialog.username }}{% endraw %}
+
{% raw %}{{ payUserDialog.user_id }}{% endraw %}
+
+ +
+
Amount Castle Owes
+
+ {% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats +
+
+ {% raw %}{{ formatFiat(payUserDialog.maxAmountFiat, payUserDialog.fiatCurrency) }}{% endraw %} +
+
+ + + + + + + + + + +
+ + Payment sent successfully! +
+ +
+ + + Send Lightning Payment + + + + + Record Payment + + + + {% raw %}{{ payUserDialog.paymentSuccess ? 'Close' : 'Cancel' }}{% endraw %} + +
+
+
+
+ {% endblock %} diff --git a/views_api.py b/views_api.py index 103452f..c18a3c1 100644 --- a/views_api.py +++ b/views_api.py @@ -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),