From ef3e2d9e0dfea6f013150e42c8a111c3d1afd629 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 22 Oct 2025 16:46:46 +0200 Subject: [PATCH] Adds balance payment feature Implements a feature that allows users to pay their outstanding balance via Lightning. The changes include: - Adds the UI elements for invoice generation and display, including QR code. - Integrates backend endpoints to generate and record payments. - Adds polling mechanism to track payments and update balance. - Creates new database models to support manual payment requests. --- models.py | 21 ++++++++ static/js/index.js | 101 ++++++++++++++++++++++++++++++------ templates/castle/index.html | 85 ++++++++++++++++++++++-------- views_api.py | 74 +++++++++++++++++++++----- 4 files changed, 232 insertions(+), 49 deletions(-) diff --git a/models.py b/models.py index 85e6a1a..c4293bc 100644 --- a/models.py +++ b/models.py @@ -132,3 +132,24 @@ class StoredUserWalletSettings(UserWalletSettings): """Stored user wallet settings with user ID""" id: str # user_id + + +class ManualPaymentRequest(BaseModel): + """Manual payment request from user to castle""" + + id: str + user_id: str + amount: int # in satoshis + description: str + status: str = "pending" # pending, approved, rejected + created_at: datetime + reviewed_at: Optional[datetime] = None + reviewed_by: Optional[str] = None # user_id of castle admin who reviewed + journal_entry_id: Optional[str] = None # set when approved + + +class CreateManualPaymentRequest(BaseModel): + """Create a manual payment request""" + + amount: int + description: str diff --git a/static/js/index.js b/static/js/index.js index 9a6cbf8..0265353 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -32,6 +32,8 @@ window.app = Vue.createApp({ payDialog: { show: false, amount: null, + paymentRequest: null, + paymentHash: null, loading: false }, settingsDialog: { @@ -311,39 +313,108 @@ window.app = Vue.createApp({ async submitPayment() { this.payDialog.loading = true try { - // First, generate an invoice for the payment - const invoiceResponse = await LNbits.api.request( + // Generate an invoice on the Castle wallet + const response = await LNbits.api.request( 'POST', - '/api/v1/payments', + '/castle/api/v1/generate-payment-invoice', this.g.user.wallets[0].inkey, { - out: false, - amount: this.payDialog.amount, - memo: `Payment to Castle - ${this.payDialog.amount} sats`, - unit: 'sat' + amount: this.payDialog.amount } ) - // Show the invoice to the user + // Show the payment request in the dialog + this.payDialog.paymentRequest = response.data.payment_request + this.payDialog.paymentHash = response.data.payment_hash + this.$q.notify({ type: 'positive', - message: 'Invoice generated! Pay it to settle your balance.', - timeout: 5000 + message: 'Invoice generated! Scan QR code or copy to pay.', + timeout: 3000 }) - // TODO: After payment, call /castle/api/v1/pay-balance to record it - // This would typically be done via a webhook or payment verification - - this.payDialog.show = false - this.payDialog.amount = null + // Poll for payment completion + this.pollForPayment(response.data.payment_hash) } catch (error) { LNbits.utils.notifyApiError(error) } finally { this.payDialog.loading = false } }, + async pollForPayment(paymentHash) { + // Poll every 2 seconds for payment status + const checkPayment = async () => { + try { + const response = await LNbits.api.request( + 'GET', + `/api/v1/payments/${paymentHash}`, + this.g.user.wallets[0].inkey + ) + + if (response.data && response.data.paid) { + // Record payment in accounting + try { + await LNbits.api.request( + 'POST', + '/castle/api/v1/record-payment', + this.g.user.wallets[0].inkey, + { + payment_hash: paymentHash + } + ) + } catch (error) { + console.error('Error recording payment:', error) + } + + this.$q.notify({ + type: 'positive', + message: 'Payment received! Your balance has been updated.', + timeout: 3000 + }) + this.payDialog.show = false + this.payDialog.paymentRequest = null + this.payDialog.amount = null + await this.loadBalance() + await this.loadTransactions() + return true + } + return false + } catch (error) { + return false + } + } + + // Check every 2 seconds for up to 5 minutes + let attempts = 0 + const maxAttempts = 150 // 5 minutes + const intervalId = setInterval(async () => { + attempts++ + const paid = await checkPayment() + if (paid || attempts >= maxAttempts) { + clearInterval(intervalId) + } + }, 2000) + }, + showManualPaymentOption() { + // TODO: Show manual payment request dialog + this.$q.notify({ + type: 'info', + message: 'Manual payment feature coming soon!', + timeout: 3000 + }) + }, + copyToClipboard(text) { + navigator.clipboard.writeText(text) + this.$q.notify({ + type: 'positive', + message: 'Copied to clipboard!', + timeout: 1000 + }) + }, showPayBalanceDialog() { this.payDialog.amount = Math.abs(this.balance.balance) + this.payDialog.paymentRequest = null + this.payDialog.paymentHash = null this.payDialog.show = true }, async showReceivableDialog() { diff --git a/templates/castle/index.html b/templates/castle/index.html index 4127b7f..71997d5 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -312,31 +312,74 @@ - - -
Pay Balance
+ +
Pay Balance
-
- Amount owed: {% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats +
+ Amount owed: {% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats +
+ +
+ + + +
+ + Generate Lightning Invoice + + + Pay Manually (Cash/Bank) + +
+
+ Cancel +
+
+
+ +
+
+
- - -
- - Generate Invoice - - Cancel +
+ + +
- + +
+ Scan the QR code or copy the invoice to pay with your Lightning wallet. + Your balance will update automatically after payment. +
+ +
+ Close +
+
diff --git a/views_api.py b/views_api.py index a11abe6..4991374 100644 --- a/views_api.py +++ b/views_api.py @@ -444,20 +444,66 @@ async def api_get_all_balances( # ===== PAYMENT ENDPOINTS ===== -@castle_api_router.post("/api/v1/pay-balance") -async def api_pay_balance( +@castle_api_router.post("/api/v1/generate-payment-invoice") +async def api_generate_payment_invoice( amount: int, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> dict: """ - Record a payment from user to castle (reduces what user owes or what castle owes user). - This should be called after an invoice is paid. + Generate an invoice on the Castle wallet for user to pay their balance. + User can then pay this invoice to settle their debt. """ - wallet_id = wallet.wallet.id + from lnbits.core.models import CreateInvoice + from lnbits.core.services import create_payment_request + + # Get castle wallet ID + castle_wallet_id = await check_castle_wallet_configured() + + # Create invoice on castle wallet + invoice_data = CreateInvoice( + out=False, + amount=amount, + memo=f"Payment from user {wallet.wallet.user[:8]} to Castle", + unit="sat", + extra={"user_id": wallet.wallet.user, "type": "castle_payment"}, + ) + + payment = await create_payment_request(castle_wallet_id, invoice_data) + + return { + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": amount, + "memo": invoice_data.memo, + } + + +@castle_api_router.post("/api/v1/record-payment") +async def api_record_payment( + payment_hash: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> dict: + """ + Record a lightning payment in accounting after invoice is paid. + This reduces what the user owes to the castle. + """ + from lnbits.core.crud.payments import get_standalone_payment + + # Get the payment details + payment = await get_standalone_payment(payment_hash) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment not found" + ) + + if not payment.paid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet paid" + ) # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( - wallet_id, AccountType.ASSET, "Accounts Receivable" + wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" ) # Get lightning account @@ -467,33 +513,35 @@ async def api_pay_balance( status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" ) - # Create journal entry + # Create journal entry to record payment # DR Lightning Balance, CR Accounts Receivable (User) + # This reduces what the user owes entry_data = CreateJournalEntry( - description=f"Payment received from user {wallet_id[:8]}", + description=f"Lightning payment from user {wallet.wallet.user[:8]}", + reference=payment_hash, lines=[ CreateEntryLine( account_id=lightning_account.id, - debit=amount, + debit=payment.amount, credit=0, description="Lightning payment received", ), CreateEntryLine( account_id=user_receivable.id, debit=0, - credit=amount, + credit=payment.amount, description="Payment applied to balance", ), ], ) - entry = await create_journal_entry(entry_data, wallet_id) + entry = await create_journal_entry(entry_data, wallet.wallet.user) # Get updated balance - balance = await get_user_balance(wallet_id) + balance = await get_user_balance(wallet.wallet.user) return { - "journal_entry": entry.dict(), + "journal_entry_id": entry.id, "new_balance": balance.balance, "message": "Payment recorded successfully", }