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:
parent
d06f46a63c
commit
1412359172
4 changed files with 278 additions and 1 deletions
10
models.py
10
models.py
|
|
@ -181,6 +181,16 @@ class RecordPayment(BaseModel):
|
|||
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):
|
||||
"""Status of a balance assertion"""
|
||||
PENDING = "pending" # Not yet checked
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ window.app = Vue.createApp({
|
|||
discrepancies: null,
|
||||
checking: 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.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) {
|
||||
return new Intl.NumberFormat().format(amount)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@
|
|||
:rows="allUserBalances"
|
||||
:columns="[
|
||||
{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"
|
||||
hide-pagination
|
||||
|
|
@ -189,6 +190,21 @@
|
|||
</div>
|
||||
</q-td>
|
||||
</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-card-section>
|
||||
</q-card>
|
||||
|
|
@ -1108,4 +1124,83 @@
|
|||
</q-card>
|
||||
</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 %}
|
||||
|
|
|
|||
111
views_api.py
111
views_api.py
|
|
@ -59,6 +59,7 @@ from .models import (
|
|||
ReceivableEntry,
|
||||
RecordPayment,
|
||||
RevenueEntry,
|
||||
SettleReceivable,
|
||||
UserBalance,
|
||||
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 =====
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue