Adds receivable entry functionality

Implements the ability to record receivables (user owes the castle).

Adds API endpoint for creating receivable entries, which includes currency conversion to satoshis if fiat currency is provided.

Integrates a UI component (receivable dialog) for superusers to record debts owed by users, enhancing financial tracking capabilities.
This commit is contained in:
padreug 2025-10-22 16:16:36 +02:00
parent b7e4e05469
commit 2a14dd2e62
4 changed files with 250 additions and 3 deletions

View file

@ -446,3 +446,12 @@ async def update_user_wallet_settings(
settings = StoredUserWalletSettings(**data.dict(), id=user_id) settings = StoredUserWalletSettings(**data.dict(), id=user_id)
await db.update("user_wallet_settings", settings) await db.update("user_wallet_settings", settings)
return settings return settings
async def get_all_user_wallet_settings() -> list[StoredUserWalletSettings]:
"""Get all user wallet settings"""
return await db.fetchall(
"SELECT * FROM user_wallet_settings ORDER BY id",
{},
StoredUserWalletSettings,
)

View file

@ -12,6 +12,7 @@ window.app = Vue.createApp({
transactions: [], transactions: [],
accounts: [], accounts: [],
currencies: [], currencies: [],
users: [],
settings: null, settings: null,
userWalletSettings: null, userWalletSettings: null,
isAdmin: false, isAdmin: false,
@ -42,6 +43,16 @@ window.app = Vue.createApp({
show: false, show: false,
userWalletId: '', userWalletId: '',
loading: false loading: false
},
receivableDialog: {
show: false,
selectedUser: '',
description: '',
amount: null,
revenueAccount: '',
reference: '',
currency: null,
loading: false
} }
} }
}, },
@ -49,18 +60,37 @@ window.app = Vue.createApp({
expenseAccounts() { expenseAccounts() {
return this.accounts.filter(a => a.account_type === 'expense') return this.accounts.filter(a => a.account_type === 'expense')
}, },
revenueAccounts() {
return this.accounts.filter(a => a.account_type === 'revenue')
},
amountLabel() { amountLabel() {
if (this.expenseDialog.currency) { if (this.expenseDialog.currency) {
return `Amount (${this.expenseDialog.currency}) *` return `Amount (${this.expenseDialog.currency}) *`
} }
return 'Amount (sats) *' return 'Amount (sats) *'
}, },
receivableAmountLabel() {
if (this.receivableDialog.currency) {
return `Amount (${this.receivableDialog.currency}) *`
}
return 'Amount (sats) *'
},
currencyOptions() { currencyOptions() {
const options = [{label: 'Satoshis (default)', value: null}] const options = [{label: 'Satoshis (default)', value: null}]
this.currencies.forEach(curr => { this.currencies.forEach(curr => {
options.push({label: curr, value: curr}) options.push({label: curr, value: curr})
}) })
return options return options
},
userOptions() {
const options = []
this.users.forEach(user => {
options.push({
label: user.username,
value: user.user_wallet_id
})
})
return options
} }
}, },
methods: { methods: {
@ -129,6 +159,18 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
async loadUsers() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/users',
this.g.user.wallets[0].adminkey
)
this.users = response.data
} catch (error) {
console.error('Error loading users:', error)
}
},
async loadSettings() { async loadSettings() {
try { try {
// Try with admin key first to check settings // Try with admin key first to check settings
@ -304,6 +346,43 @@ window.app = Vue.createApp({
this.payDialog.amount = Math.abs(this.balance.balance) this.payDialog.amount = Math.abs(this.balance.balance)
this.payDialog.show = true this.payDialog.show = true
}, },
async showReceivableDialog() {
// Load users if not already loaded
if (this.users.length === 0 && this.isSuperUser) {
await this.loadUsers()
}
this.receivableDialog.show = true
},
async submitReceivable() {
this.receivableDialog.loading = true
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/entries/receivable',
this.g.user.wallets[0].adminkey,
{
description: this.receivableDialog.description,
amount: this.receivableDialog.amount,
revenue_account: this.receivableDialog.revenueAccount,
user_wallet: this.receivableDialog.selectedUser,
reference: this.receivableDialog.reference || null,
currency: this.receivableDialog.currency || null
}
)
this.$q.notify({
type: 'positive',
message: 'Receivable added successfully'
})
this.receivableDialog.show = false
this.resetReceivableDialog()
await this.loadBalance()
await this.loadTransactions()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.receivableDialog.loading = false
}
},
resetExpenseDialog() { resetExpenseDialog() {
this.expenseDialog.description = '' this.expenseDialog.description = ''
this.expenseDialog.amount = null this.expenseDialog.amount = null
@ -312,6 +391,14 @@ window.app = Vue.createApp({
this.expenseDialog.reference = '' this.expenseDialog.reference = ''
this.expenseDialog.currency = null this.expenseDialog.currency = null
}, },
resetReceivableDialog() {
this.receivableDialog.selectedUser = ''
this.receivableDialog.description = ''
this.receivableDialog.amount = null
this.receivableDialog.revenueAccount = ''
this.receivableDialog.reference = ''
this.receivableDialog.currency = null
},
formatSats(amount) { formatSats(amount) {
return new Intl.NumberFormat().format(amount) return new Intl.NumberFormat().format(amount)
}, },
@ -330,5 +417,9 @@ window.app = Vue.createApp({
await this.loadCurrencies() await this.loadCurrencies()
await this.loadSettings() await this.loadSettings()
await this.loadUserWallet() await this.loadUserWallet()
// Load users if super user (for receivable dialog)
if (this.isSuperUser) {
await this.loadUsers()
}
} }
}) })

View file

@ -153,6 +153,20 @@
You must configure your wallet first You must configure your wallet first
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<q-btn
v-if="isSuperUser"
color="orange"
@click="showReceivableDialog"
:disable="!castleWalletConfigured"
>
Add Receivable
<q-tooltip v-if="!castleWalletConfigured">
Castle wallet must be configured first
</q-tooltip>
<q-tooltip v-else>
Record when a user owes the Castle
</q-tooltip>
</q-btn>
<q-btn color="secondary" @click="loadTransactions"> <q-btn color="secondary" @click="loadTransactions">
View Transactions View Transactions
</q-btn> </q-btn>
@ -410,4 +424,82 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Receivable Dialog -->
<q-dialog v-model="receivableDialog.show" position="top">
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitReceivable" class="q-gutter-md">
<div class="text-h6 q-mb-md">Add Receivable</div>
<q-select
filled
dense
v-model="receivableDialog.selectedUser"
:options="userOptions"
option-label="label"
option-value="value"
emit-value
map-options
label="User *"
></q-select>
<q-input
filled
dense
v-model.trim="receivableDialog.description"
label="Description *"
placeholder="e.g., Room rental for 5 days"
></q-input>
<q-select
filled
dense
v-model="receivableDialog.currency"
:options="currencyOptions"
option-label="label"
option-value="value"
emit-value
map-options
label="Currency"
></q-select>
<q-input
filled
dense
v-model.number="receivableDialog.amount"
type="number"
:label="receivableAmountLabel"
min="0.01"
step="0.01"
></q-input>
<q-select
filled
dense
v-model="receivableDialog.revenueAccount"
:options="revenueAccounts"
option-label="name"
option-value="id"
emit-value
map-options
label="Revenue Category *"
></q-select>
<q-input
filled
dense
v-model.trim="receivableDialog.reference"
label="Reference (optional)"
placeholder="e.g., Invoice #456"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="receivableDialog.loading">
Submit Receivable
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %} {% endblock %}

View file

@ -20,6 +20,7 @@ from .crud import (
get_all_accounts, get_all_accounts,
get_all_journal_entries, get_all_journal_entries,
get_all_user_balances, get_all_user_balances,
get_all_user_wallet_settings,
get_journal_entries_by_user, get_journal_entries_by_user,
get_journal_entry, get_journal_entry,
get_or_create_user_account, get_or_create_user_account,
@ -282,7 +283,32 @@ async def api_create_receivable_entry(
""" """
Create an accounts receivable entry (user owes castle). Create an accounts receivable entry (user owes castle).
Admin only to prevent abuse. Admin only to prevent abuse.
If currency is provided, amount is converted from fiat to satoshis.
""" """
# Handle currency conversion
amount_sats = int(data.amount)
metadata = {}
if data.currency:
# Validate currency
if data.currency.upper() not in allowed_currencies():
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}",
)
# Convert fiat to satoshis
amount_sats = await fiat_amount_as_satoshis(data.amount, data.currency)
# Store currency metadata
metadata = {
"fiat_currency": data.currency.upper(),
"fiat_amount": round(data.amount, ndigits=3),
"fiat_rate": amount_sats / data.amount if data.amount > 0 else 0,
"btc_rate": (data.amount / amount_sats * 100_000_000) if amount_sats > 0 else 0,
}
# Get or create revenue account # Get or create revenue account
revenue_account = await get_account_by_name(data.revenue_account) revenue_account = await get_account_by_name(data.revenue_account)
if not revenue_account: if not revenue_account:
@ -300,21 +326,24 @@ async def api_create_receivable_entry(
# Create journal entry # Create journal entry
# DR Accounts Receivable (User), CR Revenue # DR Accounts Receivable (User), CR Revenue
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
entry_data = CreateJournalEntry( entry_data = CreateJournalEntry(
description=data.description, description=data.description + description_suffix,
reference=data.reference, reference=data.reference,
lines=[ lines=[
CreateEntryLine( CreateEntryLine(
account_id=user_receivable.id, account_id=user_receivable.id,
debit=data.amount, debit=amount_sats,
credit=0, credit=0,
description=f"Amount owed by user {data.user_wallet[:8]}", description=f"Amount owed by user {data.user_wallet[:8]}",
metadata=metadata,
), ),
CreateEntryLine( CreateEntryLine(
account_id=revenue_account.id, account_id=revenue_account.id,
debit=0, debit=0,
credit=data.amount, credit=amount_sats,
description="Revenue earned", description="Revenue earned",
metadata=metadata,
), ),
], ],
) )
@ -558,6 +587,32 @@ async def api_update_settings(
# ===== USER WALLET ENDPOINTS ===== # ===== USER WALLET ENDPOINTS =====
@castle_api_router.get("/api/v1/users")
async def api_get_all_users(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> list[dict]:
"""Get all users who have configured their wallet (admin only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise truncate user_id
username = user.username if user and user.username else setting.id[:16] + "..."
users.append({
"user_id": setting.id,
"user_wallet_id": setting.user_wallet_id,
"username": username,
})
return users
@castle_api_router.get("/api/v1/user/wallet") @castle_api_router.get("/api/v1/user/wallet")
async def api_get_user_wallet( async def api_get_user_wallet(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),