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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
111
views_api.py
111
views_api.py
|
|
@ -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 =====
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue