diff --git a/models.py b/models.py index 904b648..391b8ac 100644 --- a/models.py +++ b/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 diff --git a/static/js/index.js b/static/js/index.js index 375f66a..9a29711 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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) }, diff --git a/templates/castle/index.html b/templates/castle/index.html index 44c86f6..1cf8d8e 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -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 @@ + @@ -1108,4 +1124,83 @@ + + + + +
Settle Receivable
+ +
+
User
+
{% raw %}{{ settleReceivableDialog.username }}{% endraw %}
+
{% raw %}{{ settleReceivableDialog.user_id }}{% endraw %}
+
+ +
+
Amount Owed
+
{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats
+
+ + + + + + + + + +
+ + Settle Receivable + + Cancel +
+
+
+
+ {% endblock %} diff --git a/views_api.py b/views_api.py index 15552b9..e54478b 100644 --- a/views_api.py +++ b/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 =====