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"""
|
"""Stored user wallet settings with user ID"""
|
||||||
|
|
||||||
id: str # 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: {
|
payDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
amount: null,
|
amount: null,
|
||||||
|
paymentRequest: null,
|
||||||
|
paymentHash: null,
|
||||||
loading: false
|
loading: false
|
||||||
},
|
},
|
||||||
settingsDialog: {
|
settingsDialog: {
|
||||||
|
|
@ -311,39 +313,108 @@ window.app = Vue.createApp({
|
||||||
async submitPayment() {
|
async submitPayment() {
|
||||||
this.payDialog.loading = true
|
this.payDialog.loading = true
|
||||||
try {
|
try {
|
||||||
// First, generate an invoice for the payment
|
// Generate an invoice on the Castle wallet
|
||||||
const invoiceResponse = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/v1/payments',
|
'/castle/api/v1/generate-payment-invoice',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
out: false,
|
amount: this.payDialog.amount
|
||||||
amount: this.payDialog.amount,
|
|
||||||
memo: `Payment to Castle - ${this.payDialog.amount} sats`,
|
|
||||||
unit: 'sat'
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Invoice generated! Pay it to settle your balance.',
|
message: 'Invoice generated! Scan QR code or copy to pay.',
|
||||||
timeout: 5000
|
timeout: 3000
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: After payment, call /castle/api/v1/pay-balance to record it
|
// Poll for payment completion
|
||||||
// This would typically be done via a webhook or payment verification
|
this.pollForPayment(response.data.payment_hash)
|
||||||
|
|
||||||
this.payDialog.show = false
|
|
||||||
this.payDialog.amount = null
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
this.payDialog.loading = false
|
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() {
|
showPayBalanceDialog() {
|
||||||
this.payDialog.amount = Math.abs(this.balance.balance)
|
this.payDialog.amount = Math.abs(this.balance.balance)
|
||||||
|
this.payDialog.paymentRequest = null
|
||||||
|
this.payDialog.paymentHash = null
|
||||||
this.payDialog.show = true
|
this.payDialog.show = true
|
||||||
},
|
},
|
||||||
async showReceivableDialog() {
|
async showReceivableDialog() {
|
||||||
|
|
|
||||||
|
|
@ -312,31 +312,74 @@
|
||||||
|
|
||||||
<!-- Pay Balance Dialog -->
|
<!-- Pay Balance Dialog -->
|
||||||
<q-dialog v-model="payDialog.show" position="top">
|
<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-card v-if="payDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card" style="min-width: 400px">
|
||||||
<q-form @submit="submitPayment" class="q-gutter-md">
|
<div class="text-h6 q-mb-md">Pay Balance</div>
|
||||||
<div class="text-h6 q-mb-md">Pay Balance</div>
|
|
||||||
|
|
||||||
<div v-if="balance" class="q-mb-md">
|
<div v-if="balance" class="q-mb-md">
|
||||||
Amount owed: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
|
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>
|
</div>
|
||||||
|
|
||||||
<q-input
|
<div class="q-mb-md">
|
||||||
filled
|
<q-input
|
||||||
dense
|
filled
|
||||||
v-model.number="payDialog.amount"
|
dense
|
||||||
type="number"
|
readonly
|
||||||
label="Amount to pay (sats) *"
|
v-model="payDialog.paymentRequest"
|
||||||
min="1"
|
label="Lightning Invoice"
|
||||||
:max="balance ? Math.abs(balance.balance) : 0"
|
>
|
||||||
></q-input>
|
<template v-slot:append>
|
||||||
|
<q-btn
|
||||||
<div class="row q-mt-lg">
|
flat
|
||||||
<q-btn unelevated color="primary" type="submit" :loading="payDialog.loading">
|
dense
|
||||||
Generate Invoice
|
icon="content_copy"
|
||||||
</q-btn>
|
@click="copyToClipboard(payDialog.paymentRequest)"
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
>
|
||||||
|
<q-tooltip>Copy invoice</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
</div>
|
</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-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
|
|
||||||
74
views_api.py
74
views_api.py
|
|
@ -444,20 +444,66 @@ async def api_get_all_balances(
|
||||||
# ===== PAYMENT ENDPOINTS =====
|
# ===== PAYMENT ENDPOINTS =====
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.post("/api/v1/pay-balance")
|
@castle_api_router.post("/api/v1/generate-payment-invoice")
|
||||||
async def api_pay_balance(
|
async def api_generate_payment_invoice(
|
||||||
amount: int,
|
amount: int,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Record a payment from user to castle (reduces what user owes or what castle owes user).
|
Generate an invoice on the Castle wallet for user to pay their balance.
|
||||||
This should be called after an invoice is paid.
|
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)
|
# Get user's receivable account (what user owes)
|
||||||
user_receivable = await get_or_create_user_account(
|
user_receivable = await get_or_create_user_account(
|
||||||
wallet_id, AccountType.ASSET, "Accounts Receivable"
|
wallet.wallet.user, AccountType.ASSET, "Accounts Receivable"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get lightning account
|
# Get lightning account
|
||||||
|
|
@ -467,33 +513,35 @@ async def api_pay_balance(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
|
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)
|
# DR Lightning Balance, CR Accounts Receivable (User)
|
||||||
|
# This reduces what the user owes
|
||||||
entry_data = CreateJournalEntry(
|
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=[
|
lines=[
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=lightning_account.id,
|
account_id=lightning_account.id,
|
||||||
debit=amount,
|
debit=payment.amount,
|
||||||
credit=0,
|
credit=0,
|
||||||
description="Lightning payment received",
|
description="Lightning payment received",
|
||||||
),
|
),
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=user_receivable.id,
|
account_id=user_receivable.id,
|
||||||
debit=0,
|
debit=0,
|
||||||
credit=amount,
|
credit=payment.amount,
|
||||||
description="Payment applied to balance",
|
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
|
# Get updated balance
|
||||||
balance = await get_user_balance(wallet_id)
|
balance = await get_user_balance(wallet.wallet.user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"journal_entry": entry.dict(),
|
"journal_entry_id": entry.id,
|
||||||
"new_balance": balance.balance,
|
"new_balance": balance.balance,
|
||||||
"message": "Payment recorded successfully",
|
"message": "Payment recorded successfully",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue