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 @@