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:
parent
eb9a3c1600
commit
ef3e2d9e0d
4 changed files with 232 additions and 49 deletions
21
models.py
21
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
74
views_api.py
74
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",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue