Adds settle receivable functionality

Implements a "Settle Receivable" feature for super users to record manual payments from users who owe money.

Introduces a dialog for inputting payment details (amount, method, description, reference), triggers an API call to record the transaction, and updates user balances and transaction history.

This is for non-lightning payments like cash, bank transfers, or checks.
This commit is contained in:
padreug 2025-10-23 02:57:21 +02:00
parent d06f46a63c
commit 1412359172
4 changed files with 278 additions and 1 deletions

View file

@ -181,6 +181,16 @@ class RecordPayment(BaseModel):
payment_hash: str payment_hash: str
class SettleReceivable(BaseModel):
"""Manually settle a receivable (user pays castle in person)"""
user_id: str
amount: int # Amount in satoshis
payment_method: str # "cash", "bank_transfer", "lightning", "other"
description: str # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
class AssertionStatus(str, Enum): class AssertionStatus(str, Enum):
"""Status of a balance assertion""" """Status of a balance assertion"""
PENDING = "pending" # Not yet checked PENDING = "pending" # Not yet checked

View file

@ -82,6 +82,17 @@ window.app = Vue.createApp({
discrepancies: null, discrepancies: null,
checking: false, checking: false,
showDiscrepancies: false showDiscrepancies: false
},
settleReceivableDialog: {
show: false,
user_id: '',
username: '',
maxAmount: 0,
amount: 0,
payment_method: 'cash',
description: '',
reference: '',
loading: false
} }
} }
}, },
@ -852,6 +863,56 @@ window.app = Vue.createApp({
this.receivableDialog.reference = '' this.receivableDialog.reference = ''
this.receivableDialog.currency = null this.receivableDialog.currency = null
}, },
showSettleReceivableDialog(userBalance) {
// Only show for users who owe castle (negative balance)
if (userBalance.balance >= 0) return
this.settleReceivableDialog = {
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: Math.abs(userBalance.balance), // Convert negative to positive
amount: Math.abs(userBalance.balance), // Default to full amount
payment_method: 'cash',
description: `Payment from ${userBalance.username}`,
reference: '',
loading: false
}
},
async submitSettleReceivable() {
this.settleReceivableDialog.loading = true
try {
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/receivables/settle',
this.g.user.wallets[0].adminkey,
{
user_id: this.settleReceivableDialog.user_id,
amount: this.settleReceivableDialog.amount,
payment_method: this.settleReceivableDialog.payment_method,
description: this.settleReceivableDialog.description,
reference: this.settleReceivableDialog.reference || null
}
)
this.$q.notify({
type: 'positive',
message: response.data.message,
timeout: 3000
})
this.settleReceivableDialog.show = false
// Reload balances
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.settleReceivableDialog.loading = false
}
},
formatSats(amount) { formatSats(amount) {
return new Intl.NumberFormat().format(amount) return new Intl.NumberFormat().format(amount)
}, },

View file

@ -162,7 +162,8 @@
:rows="allUserBalances" :rows="allUserBalances"
:columns="[ :columns="[
{name: 'user', label: 'User', field: 'username', align: 'left'}, {name: 'user', label: 'User', field: 'username', align: 'left'},
{name: 'balance', label: 'Amount Owed', field: 'balance', align: 'right'} {name: 'balance', label: 'Amount Owed', field: 'balance', align: 'right'},
{name: 'actions', label: 'Actions', align: 'center'}
]" ]"
row-key="user_id" row-key="user_id"
hide-pagination hide-pagination
@ -189,6 +190,21 @@
</div> </div>
</q-td> </q-td>
</template> </template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn
v-if="props.row.balance < 0"
flat
dense
size="sm"
color="primary"
icon="payments"
@click="showSettleReceivableDialog(props.row)"
>
<q-tooltip>Settle receivable (user pays)</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -1108,4 +1124,83 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Settle Receivable Dialog (Super User Only) -->
<q-dialog v-model="settleReceivableDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 400px">
<q-form @submit="submitSettleReceivable">
<h6 class="q-my-none q-mb-md">Settle Receivable</h6>
<div class="q-mb-md">
<div class="text-subtitle2">User</div>
<div>{% raw %}{{ settleReceivableDialog.username }}{% endraw %}</div>
<div class="text-caption text-grey">{% raw %}{{ settleReceivableDialog.user_id }}{% endraw %}</div>
</div>
<div class="q-mb-md">
<div class="text-subtitle2">Amount Owed</div>
<div class="text-negative text-h6">{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats</div>
</div>
<q-input
filled
dense
v-model.number="settleReceivableDialog.amount"
type="number"
label="Settlement Amount (sats) *"
hint="Amount user is paying (max: owed amount)"
:max="settleReceivableDialog.maxAmount"
:rules="[
val => val !== null && val !== undefined && val !== '' || 'Amount is required',
val => val > 0 || 'Amount must be positive',
val => val <= settleReceivableDialog.maxAmount || 'Cannot exceed owed amount'
]"
></q-input>
<q-select
filled
dense
v-model="settleReceivableDialog.payment_method"
:options="[
{label: 'Cash', value: 'cash'},
{label: 'Bank Transfer', value: 'bank_transfer'},
{label: 'Check', value: 'check'},
{label: 'Other', value: 'other'}
]"
option-label="label"
option-value="value"
emit-value
map-options
label="Payment Method *"
:rules="[val => !!val || 'Payment method is required']"
></q-select>
<q-input
filled
dense
v-model="settleReceivableDialog.description"
type="text"
label="Description *"
hint="Description of the payment"
:rules="[val => !!val || 'Description is required']"
></q-input>
<q-input
filled
dense
v-model="settleReceivableDialog.reference"
type="text"
label="Reference (optional)"
hint="Receipt number, transaction ID, etc."
></q-input>
<div class="row q-mt-md q-gutter-sm">
<q-btn unelevated color="primary" type="submit" :loading="settleReceivableDialog.loading">
Settle Receivable
</q-btn>
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %} {% endblock %}

View file

@ -59,6 +59,7 @@ from .models import (
ReceivableEntry, ReceivableEntry,
RecordPayment, RecordPayment,
RevenueEntry, RevenueEntry,
SettleReceivable,
UserBalance, UserBalance,
UserWalletSettings, UserWalletSettings,
) )
@ -727,6 +728,116 @@ async def api_pay_user(
} }
@castle_api_router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Manually settle a receivable (record when user pays castle in person).
This endpoint is for non-lightning payments like:
- Cash payments
- Bank transfers
- Other manual settlements
Admin only.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can settle receivables",
)
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
data.user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Cash",
"bank_transfer": "Bank Account",
"check": "Bank Account",
"other": "Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
payment_account = await get_account_by_name(account_name)
# If account doesn't exist, try to find or create a generic one
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Create journal entry
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt
# Add meta information for audit trail
entry_meta = {
"source": "manual_settlement",
"payment_method": data.payment_method,
"settled_by": wallet.wallet.user,
"payer_user_id": data.user_id,
}
entry_data = CreateJournalEntry(
description=data.description,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
flag=JournalEntryFlag.CLEARED, # Manual payments are immediately cleared
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=payment_account.id,
debit=data.amount,
credit=0,
description=f"Payment received via {data.payment_method}",
),
CreateEntryLine(
account_id=user_receivable.id,
debit=0,
credit=data.amount,
description="Receivable settled",
),
],
)
entry = await create_journal_entry(entry_data, wallet.wallet.id)
# Get updated balance
balance = await get_user_balance(data.user_id)
return {
"journal_entry_id": entry.id,
"user_id": data.user_id,
"amount_settled": data.amount,
"payment_method": data.payment_method,
"new_balance": balance.balance,
"message": f"Receivable settled successfully via {data.payment_method}",
}
# ===== SETTINGS ENDPOINTS ===== # ===== SETTINGS ENDPOINTS =====