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.
This commit is contained in:
padreug 2025-10-22 16:46:46 +02:00
parent eb9a3c1600
commit ef3e2d9e0d
4 changed files with 232 additions and 49 deletions

View file

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

View file

@ -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() {

View file

@ -312,31 +312,74 @@
<!-- Pay Balance Dialog -->
<q-dialog v-model="payDialog.show" position="top">
<q-card v-if="payDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitPayment" class="q-gutter-md">
<div class="text-h6 q-mb-md">Pay Balance</div>
<q-card v-if="payDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card" style="min-width: 400px">
<div class="text-h6 q-mb-md">Pay Balance</div>
<div v-if="balance" class="q-mb-md">
Amount owed: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
<div v-if="balance" class="q-mb-md">
Amount owed: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
</div>
<div v-if="!payDialog.paymentRequest">
<q-form @submit="submitPayment" class="q-gutter-md">
<q-input
filled
dense
v-model.number="payDialog.amount"
type="number"
label="Amount to pay (sats) *"
min="1"
:max="balance ? Math.abs(balance.balance) : 0"
></q-input>
<div class="row q-mt-lg q-gutter-sm">
<q-btn unelevated color="primary" type="submit" :loading="payDialog.loading">
Generate Lightning Invoice
</q-btn>
<q-btn unelevated color="orange" @click="showManualPaymentOption" :loading="payDialog.loading">
Pay Manually (Cash/Bank)
</q-btn>
</div>
<div class="row q-mt-sm">
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
</div>
</q-form>
</div>
<div v-else>
<div class="q-mb-md text-center">
<lnbits-qrcode :value="payDialog.paymentRequest" :options="{width: 280}"></lnbits-qrcode>
</div>
<q-input
filled
dense
v-model.number="payDialog.amount"
type="number"
label="Amount to pay (sats) *"
min="1"
:max="balance ? Math.abs(balance.balance) : 0"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="payDialog.loading">
Generate Invoice
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
<div class="q-mb-md">
<q-input
filled
dense
readonly
v-model="payDialog.paymentRequest"
label="Lightning Invoice"
>
<template v-slot:append>
<q-btn
flat
dense
icon="content_copy"
@click="copyToClipboard(payDialog.paymentRequest)"
>
<q-tooltip>Copy invoice</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</q-form>
<div class="text-caption text-grey q-mb-md">
Scan the QR code or copy the invoice to pay with your Lightning wallet.
Your balance will update automatically after payment.
</div>
<div class="row">
<q-btn v-close-popup flat color="grey">Close</q-btn>
</div>
</div>
</q-card>
</q-dialog>

View file

@ -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",
}