Adds manual payment request functionality

Enables users to request manual payments from the Castle and provides admin functions to approve or reject these requests.

Introduces the `manual_payment_requests` table and related CRUD operations.
Adds API endpoints for creating, retrieving, approving, and rejecting manual payment requests.
Updates the UI to allow users to request payments and for admins to review pending requests.
This commit is contained in:
padreug 2025-10-22 18:02:07 +02:00
parent 3a26d963dc
commit c2d9b39f29
5 changed files with 520 additions and 11 deletions

140
crud.py
View file

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

View file

@ -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);
"""
)

View file

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

View file

@ -90,14 +90,22 @@
<div class="text-subtitle2" v-else>
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %}
</div>
<q-btn
v-if="balance.balance < 0 && !isSuperUser"
color="primary"
class="q-mt-md"
@click="showPayBalanceDialog"
>
Pay Balance
</q-btn>
<div class="q-mt-md q-gutter-sm">
<q-btn
v-if="balance.balance < 0 && !isSuperUser"
color="primary"
@click="showPayBalanceDialog"
>
Pay Balance
</q-btn>
<q-btn
v-if="balance.balance > 0 && !isSuperUser"
color="secondary"
@click="showManualPaymentDialog"
>
Request Manual Payment
</q-btn>
</div>
</div>
<div v-else>
<q-spinner color="primary" size="md"></q-spinner>
@ -145,6 +153,49 @@
</q-card-section>
</q-card>
<!-- Pending Manual Payment Requests (Super User Only) -->
<q-card v-if="isSuperUser && pendingManualPaymentRequests.length > 0">
<q-card-section>
<h6 class="q-my-none q-mb-md">Pending Manual Payment Requests</h6>
<q-list separator>
<q-item v-for="request in pendingManualPaymentRequests" :key="request.id">
<q-item-section>
<q-item-label>{% raw %}{{ request.description }}{% endraw %}</q-item-label>
<q-item-label caption>
User: {% raw %}{{ request.user_id.substring(0, 16) }}...{% endraw %}
</q-item-label>
<q-item-label caption>
Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>{% raw %}{{ formatSats(request.amount) }} sats{% endraw %}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-xs">
<q-btn
size="sm"
color="positive"
@click="approveManualPaymentRequest(request.id)"
:loading="request.approving"
>
Approve
</q-btn>
<q-btn
size="sm"
color="negative"
@click="rejectManualPaymentRequest(request.id)"
:loading="request.rejecting"
>
Reject
</q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Quick Actions -->
<q-card>
<q-card-section>
@ -411,6 +462,57 @@
</q-card>
</q-dialog>
<!-- Manual Payment Request Dialog -->
<q-dialog v-model="manualPaymentDialog.show" position="top">
<q-card v-if="manualPaymentDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card" style="min-width: 400px">
<q-form @submit="submitManualPaymentRequest" class="q-gutter-md">
<div class="text-h6 q-mb-md">Request Manual Payment</div>
<div class="text-caption text-grey q-mb-md">
Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance.
</div>
<div v-if="balance" class="q-mb-md">
<div>
Current balance: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
</div>
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-body2 q-mt-xs">
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
<strong>{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}</strong>
</span>
</div>
</div>
<q-input
filled
dense
v-model.number="manualPaymentDialog.amount"
type="number"
label="Amount to request (sats) *"
min="1"
:max="balance ? Math.abs(balance.balance) : 0"
:rules="[val => !!val || 'Amount is required', val => val > 0 || 'Amount must be positive']"
></q-input>
<q-input
filled
dense
v-model="manualPaymentDialog.description"
type="text"
label="Description *"
:rules="[val => !!val || 'Description is required']"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="manualPaymentDialog.loading">
Submit Request
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-sm">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Settings Dialog -->
<q-dialog v-model="settingsDialog.show" position="top">
<q-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">

View file

@ -11,20 +11,26 @@ from lnbits.decorators import (
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from .crud import (
approve_manual_payment_request,
create_account,
create_journal_entry,
create_manual_payment_request,
get_account,
get_account_balance,
get_account_by_name,
get_account_transactions,
get_all_accounts,
get_all_journal_entries,
get_all_manual_payment_requests,
get_all_user_balances,
get_all_user_wallet_settings,
get_journal_entries_by_user,
get_journal_entry,
get_manual_payment_request,
get_or_create_user_account,
get_user_balance,
get_user_manual_payment_requests,
reject_manual_payment_request,
)
from .models import (
Account,
@ -33,9 +39,11 @@ from .models import (
CreateAccount,
CreateEntryLine,
CreateJournalEntry,
CreateManualPaymentRequest,
ExpenseEntry,
GeneratePaymentInvoice,
JournalEntry,
ManualPaymentRequest,
ReceivableEntry,
RecordPayment,
RevenueEntry,
@ -741,3 +749,130 @@ async def api_update_user_wallet(
detail="User wallet ID is required",
)
return await update_user_wallet(user.id, data)
# ===== MANUAL PAYMENT REQUESTS =====
@castle_api_router.post("/api/v1/manual-payment-request")
async def api_create_manual_payment_request(
data: CreateManualPaymentRequest,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> ManualPaymentRequest:
"""Create a manual payment request for the Castle to review"""
return await create_manual_payment_request(
wallet.wallet.user, data.amount, data.description
)
@castle_api_router.get("/api/v1/manual-payment-requests")
async def api_get_manual_payment_requests(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[ManualPaymentRequest]:
"""Get manual payment requests for the current user"""
return await get_user_manual_payment_requests(wallet.wallet.user)
@castle_api_router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (Castle admin only)"""
await check_super_user(wallet.wallet.user)
return await get_all_manual_payment_requests(status)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
await check_super_user(wallet.wallet.user)
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
# Get castle wallet from settings
castle_wallet_id = await check_castle_wallet_configured()
# Get or create liability account for user (castle owes the user)
liability_account = await get_or_create_user_account(
request.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the Lightning asset account
lightning_account = await get_account_by_name("Lightning Balance")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Lightning Balance account not found",
)
# Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased)
# This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes
journal_entry = await create_journal_entry(
CreateJournalEntry(
description=f"Manual payment to user: {request.description}",
reference=f"MPR-{request.id}",
lines=[
CreateEntryLine(
account_id=liability_account.id,
debit=request.amount, # Decrease liability (castle owes less)
credit=0,
description="Payment to user",
),
CreateEntryLine(
account_id=lightning_account.id,
debit=0,
credit=request.amount, # Decrease asset (lightning balance reduced)
description="Payment from castle",
),
],
),
castle_wallet_id,
)
# Approve the request
return await approve_manual_payment_request(
request_id, wallet.wallet.user, journal_entry.id
)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ManualPaymentRequest:
"""Reject a manual payment request (Castle admin only)"""
await check_super_user(wallet.wallet.user)
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
return await reject_manual_payment_request(request_id, wallet.wallet.user)