From c2d9b39f292bb1305ccd8b9f2582c9de4a22412b Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 22 Oct 2025 18:02:07 +0200 Subject: [PATCH] Adds manual payment request functionality Enables users to request manual payments from the Castle and provides admin functions to approve or reject these requests. Introduces the `manual_payment_requests` table and related CRUD operations. Adds API endpoints for creating, retrieving, approving, and rejecting manual payment requests. Updates the UI to allow users to request payments and for admins to review pending requests. --- crud.py | 140 ++++++++++++++++++++++++++++++++++++ migrations.py | 33 +++++++++ static/js/index.js | 105 ++++++++++++++++++++++++++- templates/castle/index.html | 118 +++++++++++++++++++++++++++--- views_api.py | 135 ++++++++++++++++++++++++++++++++++ 5 files changed, 520 insertions(+), 11 deletions(-) diff --git a/crud.py b/crud.py index cef3171..5439e1d 100644 --- a/crud.py +++ b/crud.py @@ -557,3 +557,143 @@ async def get_all_user_wallet_settings() -> list[StoredUserWalletSettings]: {}, StoredUserWalletSettings, ) + + +# ===== MANUAL PAYMENT REQUESTS ===== + + +async def create_manual_payment_request( + user_id: str, amount: int, description: str +) -> "ManualPaymentRequest": + """Create a new manual payment request""" + from .models import ManualPaymentRequest + + request_id = urlsafe_short_hash() + request = ManualPaymentRequest( + id=request_id, + user_id=user_id, + amount=amount, + description=description, + status="pending", + created_at=datetime.now(), + ) + + await db.execute( + """ + INSERT INTO manual_payment_requests (id, user_id, amount, description, status, created_at) + VALUES (:id, :user_id, :amount, :description, :status, :created_at) + """, + { + "id": request.id, + "user_id": request.user_id, + "amount": request.amount, + "description": request.description, + "status": request.status, + "created_at": request.created_at, + }, + ) + + return request + + +async def get_manual_payment_request(request_id: str) -> Optional["ManualPaymentRequest"]: + """Get a manual payment request by ID""" + from .models import ManualPaymentRequest + + return await db.fetchone( + "SELECT * FROM manual_payment_requests WHERE id = :id", + {"id": request_id}, + ManualPaymentRequest, + ) + + +async def get_user_manual_payment_requests( + user_id: str, limit: int = 100 +) -> list["ManualPaymentRequest"]: + """Get all manual payment requests for a specific user""" + from .models import ManualPaymentRequest + + return await db.fetchall( + """ + SELECT * FROM manual_payment_requests + WHERE user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """, + {"user_id": user_id, "limit": limit}, + ManualPaymentRequest, + ) + + +async def get_all_manual_payment_requests( + status: Optional[str] = None, limit: int = 100 +) -> list["ManualPaymentRequest"]: + """Get all manual payment requests, optionally filtered by status""" + from .models import ManualPaymentRequest + + if status: + return await db.fetchall( + """ + SELECT * FROM manual_payment_requests + WHERE status = :status + ORDER BY created_at DESC + LIMIT :limit + """, + {"status": status, "limit": limit}, + ManualPaymentRequest, + ) + else: + return await db.fetchall( + """ + SELECT * FROM manual_payment_requests + ORDER BY created_at DESC + LIMIT :limit + """, + {"limit": limit}, + ManualPaymentRequest, + ) + + +async def approve_manual_payment_request( + request_id: str, reviewed_by: str, journal_entry_id: str +) -> Optional["ManualPaymentRequest"]: + """Approve a manual payment request""" + from .models import ManualPaymentRequest + + await db.execute( + """ + UPDATE manual_payment_requests + SET status = 'approved', reviewed_at = :reviewed_at, reviewed_by = :reviewed_by, journal_entry_id = :journal_entry_id + WHERE id = :id + """, + { + "id": request_id, + "reviewed_at": datetime.now(), + "reviewed_by": reviewed_by, + "journal_entry_id": journal_entry_id, + }, + ) + + return await get_manual_payment_request(request_id) + + +async def reject_manual_payment_request( + request_id: str, reviewed_by: str +) -> Optional["ManualPaymentRequest"]: + """Reject a manual payment request""" + from .models import ManualPaymentRequest + + await db.execute( + """ + UPDATE manual_payment_requests + SET status = 'rejected', reviewed_at = :reviewed_at, reviewed_by = :reviewed_by + WHERE id = :id + """, + { + "id": request_id, + "reviewed_at": datetime.now(), + "reviewed_by": reviewed_by, + }, + ) + + return await get_manual_payment_request(request_id) diff --git a/migrations.py b/migrations.py index 2144a8e..def1667 100644 --- a/migrations.py +++ b/migrations.py @@ -155,3 +155,36 @@ async def m004_user_wallet_settings(db): ); """ ) + + +async def m005_manual_payment_requests(db): + """ + Create manual_payment_requests table for user payment requests to Castle. + """ + await db.execute( + f""" + CREATE TABLE manual_payment_requests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + amount INTEGER NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + reviewed_at TIMESTAMP, + reviewed_by TEXT, + journal_entry_id TEXT + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status); + """ + ) diff --git a/static/js/index.js b/static/js/index.js index 1553b27..cee8e08 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -57,7 +57,14 @@ window.app = Vue.createApp({ reference: '', currency: null, loading: false - } + }, + manualPaymentDialog: { + show: false, + amount: null, + description: '', + loading: false + }, + manualPaymentRequests: [] } }, watch: { @@ -104,6 +111,9 @@ window.app = Vue.createApp({ }) }) return options + }, + pendingManualPaymentRequests() { + return this.manualPaymentRequests.filter(r => r.status === 'pending') } }, methods: { @@ -422,13 +432,101 @@ window.app = Vue.createApp({ }, 2000) }, showManualPaymentOption() { - // TODO: Show manual payment request dialog + // This is for when user wants to pay their debt manually + // For now, just notify them to contact castle this.$q.notify({ type: 'info', - message: 'Manual payment feature coming soon!', + message: 'Please contact Castle directly to arrange manual payment.', timeout: 3000 }) }, + showManualPaymentDialog() { + // This is for when Castle owes user and they want to request manual payment + this.manualPaymentDialog.amount = Math.abs(this.balance.balance) + this.manualPaymentDialog.description = '' + this.manualPaymentDialog.show = true + }, + async submitManualPaymentRequest() { + this.manualPaymentDialog.loading = true + try { + await LNbits.api.request( + 'POST', + '/castle/api/v1/manual-payment-request', + this.g.user.wallets[0].inkey, + { + amount: this.manualPaymentDialog.amount, + description: this.manualPaymentDialog.description + } + ) + this.$q.notify({ + type: 'positive', + message: 'Manual payment request submitted successfully!' + }) + this.manualPaymentDialog.show = false + this.manualPaymentDialog.amount = null + this.manualPaymentDialog.description = '' + await this.loadManualPaymentRequests() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.manualPaymentDialog.loading = false + } + }, + async loadManualPaymentRequests() { + try { + // If super user, load all requests; otherwise load user's own requests + const endpoint = this.isSuperUser + ? '/castle/api/v1/manual-payment-requests/all' + : '/castle/api/v1/manual-payment-requests' + const key = this.isSuperUser + ? this.g.user.wallets[0].adminkey + : this.g.user.wallets[0].inkey + + const response = await LNbits.api.request( + 'GET', + endpoint, + key, + this.isSuperUser ? {status: 'pending'} : {} + ) + this.manualPaymentRequests = response.data + } catch (error) { + console.error('Error loading manual payment requests:', error) + } + }, + async approveManualPaymentRequest(requestId) { + try { + await LNbits.api.request( + 'POST', + `/castle/api/v1/manual-payment-requests/${requestId}/approve`, + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Manual payment request approved!' + }) + await this.loadManualPaymentRequests() + await this.loadBalance() + await this.loadTransactions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async rejectManualPaymentRequest(requestId) { + try { + await LNbits.api.request( + 'POST', + `/castle/api/v1/manual-payment-requests/${requestId}/reject`, + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'warning', + message: 'Manual payment request rejected' + }) + await this.loadManualPaymentRequests() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, copyToClipboard(text) { navigator.clipboard.writeText(text) this.$q.notify({ @@ -558,6 +656,7 @@ window.app = Vue.createApp({ await this.loadTransactions() await this.loadAccounts() await this.loadCurrencies() + await this.loadManualPaymentRequests() // Load users if super user (for receivable dialog) if (this.isSuperUser) { await this.loadUsers() diff --git a/templates/castle/index.html b/templates/castle/index.html index fbe7ef2..fac863f 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -90,14 +90,22 @@
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %}
- - Pay Balance - +
+ + Pay Balance + + + Request Manual Payment + +
@@ -145,6 +153,49 @@ + + + +
Pending Manual Payment Requests
+ + + + {% raw %}{{ request.description }}{% endraw %} + + User: {% raw %}{{ request.user_id.substring(0, 16) }}...{% endraw %} + + + Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %} + + + + {% raw %}{{ formatSats(request.amount) }} sats{% endraw %} + + +
+ + Approve + + + Reject + +
+
+
+
+
+
+ @@ -411,6 +462,57 @@ + + + + +
Request Manual Payment
+ +
+ Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance. +
+ +
+
+ Current balance: {% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats +
+
+ + {% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %} + +
+
+ + + + + +
+ + Submit Request + + Cancel +
+
+
+
+ diff --git a/views_api.py b/views_api.py index 81784b4..be9aeb6 100644 --- a/views_api.py +++ b/views_api.py @@ -11,20 +11,26 @@ from lnbits.decorators import ( from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis from .crud import ( + approve_manual_payment_request, create_account, create_journal_entry, + create_manual_payment_request, get_account, get_account_balance, get_account_by_name, get_account_transactions, get_all_accounts, get_all_journal_entries, + get_all_manual_payment_requests, get_all_user_balances, get_all_user_wallet_settings, get_journal_entries_by_user, get_journal_entry, + get_manual_payment_request, get_or_create_user_account, get_user_balance, + get_user_manual_payment_requests, + reject_manual_payment_request, ) from .models import ( Account, @@ -33,9 +39,11 @@ from .models import ( CreateAccount, CreateEntryLine, CreateJournalEntry, + CreateManualPaymentRequest, ExpenseEntry, GeneratePaymentInvoice, JournalEntry, + ManualPaymentRequest, ReceivableEntry, RecordPayment, RevenueEntry, @@ -741,3 +749,130 @@ async def api_update_user_wallet( detail="User wallet ID is required", ) return await update_user_wallet(user.id, data) + + +# ===== MANUAL PAYMENT REQUESTS ===== + + +@castle_api_router.post("/api/v1/manual-payment-request") +async def api_create_manual_payment_request( + data: CreateManualPaymentRequest, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> ManualPaymentRequest: + """Create a manual payment request for the Castle to review""" + return await create_manual_payment_request( + wallet.wallet.user, data.amount, data.description + ) + + +@castle_api_router.get("/api/v1/manual-payment-requests") +async def api_get_manual_payment_requests( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> list[ManualPaymentRequest]: + """Get manual payment requests for the current user""" + return await get_user_manual_payment_requests(wallet.wallet.user) + + +@castle_api_router.get("/api/v1/manual-payment-requests/all") +async def api_get_all_manual_payment_requests( + status: str = None, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> list[ManualPaymentRequest]: + """Get all manual payment requests (Castle admin only)""" + await check_super_user(wallet.wallet.user) + return await get_all_manual_payment_requests(status) + + +@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve") +async def api_approve_manual_payment_request( + request_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> ManualPaymentRequest: + """Approve a manual payment request and create accounting entry (Castle admin only)""" + from lnbits.settings import settings as lnbits_settings + + await check_super_user(wallet.wallet.user) + + # Get the request + request = await get_manual_payment_request(request_id) + if not request: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Manual payment request not found", + ) + + if request.status != "pending": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Request already {request.status}", + ) + + # Get castle wallet from settings + castle_wallet_id = await check_castle_wallet_configured() + + # Get or create liability account for user (castle owes the user) + liability_account = await get_or_create_user_account( + request.user_id, AccountType.LIABILITY, "Accounts Payable" + ) + + # Get the Lightning asset account + lightning_account = await get_account_by_name("Lightning Balance") + if not lightning_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Lightning Balance account not found", + ) + + # Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased) + # This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes + journal_entry = await create_journal_entry( + CreateJournalEntry( + description=f"Manual payment to user: {request.description}", + reference=f"MPR-{request.id}", + lines=[ + CreateEntryLine( + account_id=liability_account.id, + debit=request.amount, # Decrease liability (castle owes less) + credit=0, + description="Payment to user", + ), + CreateEntryLine( + account_id=lightning_account.id, + debit=0, + credit=request.amount, # Decrease asset (lightning balance reduced) + description="Payment from castle", + ), + ], + ), + castle_wallet_id, + ) + + # Approve the request + return await approve_manual_payment_request( + request_id, wallet.wallet.user, journal_entry.id + ) + + +@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject") +async def api_reject_manual_payment_request( + request_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> ManualPaymentRequest: + """Reject a manual payment request (Castle admin only)""" + await check_super_user(wallet.wallet.user) + + # Get the request + request = await get_manual_payment_request(request_id) + if not request: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Manual payment request not found", + ) + + if request.status != "pending": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Request already {request.status}", + ) + + return await reject_manual_payment_request(request_id, wallet.wallet.user)