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)