Adds expense tracking module

Adds a new module for tracking user expenses.

The module includes:
- Configuration settings for the LNbits API endpoint and timeouts.
- An ExpensesAPI service for fetching accounts and submitting expense entries.
- A UI component for adding expenses, including account selection and form input.
- Dependency injection for the ExpensesAPI service.

This allows users to submit expense entries with account selection and reference data, which will be linked to their wallet.
This commit is contained in:
padreug 2025-11-07 16:21:59 +01:00
parent 678ccff694
commit 9ed674d0f3
8 changed files with 975 additions and 1 deletions

View file

@ -93,6 +93,20 @@ export const appConfig: AppConfig = {
pollingInterval: 10000 // 10 seconds for polling updates
}
}
},
expenses: {
name: 'expenses',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
timeout: 30000 // 30 seconds for API requests
},
defaultCurrency: 'sats',
maxExpenseAmount: 1000000, // 1M sats
requireDescription: true
}
}
},

View file

@ -16,6 +16,7 @@ import chatModule from './modules/chat'
import eventsModule from './modules/events'
import marketModule from './modules/market'
import walletModule from './modules/wallet'
import expensesModule from './modules/expenses'
// Root component
import App from './App.vue'
@ -43,7 +44,8 @@ export async function createAppInstance() {
...chatModule.routes || [],
...eventsModule.routes || [],
...marketModule.routes || [],
...walletModule.routes || []
...walletModule.routes || [],
...expensesModule.routes || []
].filter(Boolean)
// Create router with all routes available immediately
@ -126,6 +128,13 @@ export async function createAppInstance() {
)
}
// Register expenses module
if (appConfig.modules.expenses?.enabled) {
moduleRegistrations.push(
pluginManager.register(expensesModule, appConfig.modules.expenses)
)
}
// Wait for all modules to register
await Promise.all(moduleRegistrations)

View file

@ -160,6 +160,9 @@ export const SERVICE_TOKENS = {
// Image upload services
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
// Expenses services
EXPENSES_API: Symbol('expensesAPI'),
} as const
// Type-safe injection helpers

View file

@ -0,0 +1,257 @@
<template>
<div class="space-y-4">
<!-- Breadcrumb showing current path -->
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
<Button
variant="ghost"
size="sm"
@click="navigateToRoot"
class="h-7 px-2"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Start
</Button>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
<div class="flex items-center gap-2">
<span
v-for="(node, index) in selectedPath"
:key="node.account.id"
class="flex items-center gap-2"
>
<Button
variant="ghost"
size="sm"
@click="navigateToLevel(index)"
class="h-7 px-2 text-muted-foreground hover:text-foreground"
>
{{ getAccountDisplayName(node.account.name) }}
</Button>
<ChevronRight
v-if="index < selectedPath.length - 1"
class="h-4 w-4 text-muted-foreground"
/>
</span>
</div>
</div>
<!-- Account selection list -->
<div class="border border-border rounded-lg bg-card">
<!-- Loading state -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading accounts...</span>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 px-4"
>
<AlertCircle class="h-8 w-8 text-destructive mb-2" />
<p class="text-sm text-destructive">{{ error }}</p>
<Button
variant="outline"
size="sm"
@click="loadAccounts"
class="mt-4"
>
<RefreshCw class="h-4 w-4 mr-2" />
Retry
</Button>
</div>
<!-- Account list -->
<div v-else-if="currentNodes.length > 0" class="divide-y divide-border">
<button
v-for="node in currentNodes"
:key="node.account.id"
@click="selectNode(node)"
type="button"
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
>
<div class="flex items-center gap-3">
<Folder
v-if="node.account.has_children"
class="h-5 w-5 text-primary"
/>
<FileText
v-else
class="h-5 w-5 text-muted-foreground"
/>
<div>
<p class="font-medium text-foreground">
{{ getAccountDisplayName(node.account.name) }}
</p>
<p
v-if="node.account.description"
class="text-sm text-muted-foreground"
>
{{ node.account.description }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
v-if="!node.account.has_children"
variant="outline"
class="text-xs"
>
{{ node.account.account_type }}
</Badge>
<ChevronRight
v-if="node.account.has_children"
class="h-5 w-5 text-muted-foreground"
/>
</div>
</button>
</div>
<!-- Empty state -->
<div
v-else
class="flex flex-col items-center justify-center py-12 px-4"
>
<Folder class="h-12 w-12 text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No accounts available</p>
</div>
</div>
<!-- Selected account display -->
<div
v-if="selectedAccount"
class="flex items-center justify-between p-4 rounded-lg border-2 border-primary bg-primary/5"
>
<div class="flex items-center gap-3">
<Check class="h-5 w-5 text-primary" />
<div>
<p class="font-medium text-foreground">Selected Account</p>
<p class="text-sm text-muted-foreground">{{ selectedAccount.name }}</p>
</div>
</div>
<Badge variant="default">{{ selectedAccount.account_type }}</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ChevronLeft,
ChevronRight,
Folder,
FileText,
Loader2,
AlertCircle,
RefreshCw,
Check
} from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Account, AccountNode } from '../types'
interface Props {
rootAccount?: string
modelValue?: Account | null
}
interface Emits {
(e: 'update:modelValue', value: Account | null): void
(e: 'account-selected', value: Account): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Inject services
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
// Component state
const isLoading = ref(false)
const error = ref<string | null>(null)
const accountHierarchy = ref<AccountNode[]>([])
const selectedPath = ref<AccountNode[]>([])
const selectedAccount = ref<Account | null>(props.modelValue ?? null)
// Current nodes to display (either root or children of selected node)
const currentNodes = computed(() => {
if (selectedPath.value.length === 0) {
return accountHierarchy.value
}
const lastNode = selectedPath.value[selectedPath.value.length - 1]
return lastNode.children
})
/**
* Get display name from full account path
* e.g., "Expenses:Groceries:Organic" -> "Organic"
*/
function getAccountDisplayName(fullName: string): string {
const parts = fullName.split(':')
return parts[parts.length - 1]
}
/**
* Load accounts from API
*/
async function loadAccounts() {
isLoading.value = true
error.value = null
try {
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
props.rootAccount
)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
console.error('[AccountSelector] Error loading accounts:', err)
} finally {
isLoading.value = false
}
}
/**
* Handle node selection
*/
function selectNode(node: AccountNode) {
if (node.account.has_children) {
// Navigate into folder
selectedPath.value.push(node)
selectedAccount.value = null
emit('update:modelValue', null)
} else {
// Select leaf account
selectedAccount.value = node.account
emit('update:modelValue', node.account)
emit('account-selected', node.account)
}
}
/**
* Navigate back to root
*/
function navigateToRoot() {
selectedPath.value = []
selectedAccount.value = null
emit('update:modelValue', null)
}
/**
* Navigate to specific level in breadcrumb
*/
function navigateToLevel(level: number) {
selectedPath.value = selectedPath.value.slice(0, level + 1)
selectedAccount.value = null
emit('update:modelValue', null)
}
// Load accounts on mount
onMounted(() => {
loadAccounts()
})
</script>

View file

@ -0,0 +1,288 @@
<template>
<div class="bg-card border border-border rounded-lg shadow-lg overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-border">
<div class="flex items-center gap-2">
<DollarSign class="h-5 w-5 text-primary" />
<h3 class="font-semibold text-foreground">Add Expense</h3>
</div>
<Button
variant="ghost"
size="icon"
@click="$emit('close')"
>
<X class="h-4 w-4" />
</Button>
</div>
<!-- Form -->
<div class="p-4 space-y-4">
<!-- Step indicator -->
<div class="flex items-center justify-center gap-2 mb-4">
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 1
? 'bg-primary text-primary-foreground'
: selectedAccount
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
]"
>
1
</div>
<div class="w-12 h-px bg-border" />
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 2
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
]"
>
2
</div>
</div>
<!-- Step 1: Account Selection -->
<div v-if="currentStep === 1">
<p class="text-sm text-muted-foreground mb-4">
Select the account for this expense
</p>
<AccountSelector
v-model="selectedAccount"
root-account="Expenses"
@account-selected="handleAccountSelected"
/>
</div>
<!-- Step 2: Expense Details -->
<div v-if="currentStep === 2">
<form @submit="onSubmit" class="space-y-4">
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Groceries for community dinner"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Amount -->
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
v-bind="componentField"
min="1"
step="1"
/>
</FormControl>
<FormDescription>
Amount in satoshis
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input
placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Account from equity checkbox -->
<FormField v-slot="{ value, handleChange }" name="isEquity">
<FormItem>
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:model-value="value"
@update:model-value="handleChange"
:disabled="isSubmitting"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Account from equity</FormLabel>
<FormDescription>
Check if this expense should be accounted from your equity
</FormDescription>
</div>
</div>
</FormItem>
</FormField>
<!-- Selected account info -->
<div class="p-3 rounded-lg bg-muted/50">
<p class="text-sm font-medium text-foreground mb-1">
Account: {{ selectedAccount?.name }}
</p>
<p
v-if="selectedAccount?.description"
class="text-xs text-muted-foreground"
>
{{ selectedAccount.description }}
</p>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
@click="currentStep = 1"
:disabled="isSubmitting"
class="flex-1"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Back
</Button>
<Button
type="submit"
:disabled="isSubmitting || !isFormValid"
class="flex-1"
>
<Loader2
v-if="isSubmitting"
class="h-4 w-4 mr-2 animate-spin"
/>
<span>{{ isSubmitting ? 'Submitting...' : 'Submit Expense' }}</span>
</Button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { DollarSign, X, ChevronLeft, Loader2 } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Account } from '../types'
import AccountSelector from './AccountSelector.vue'
interface Emits {
(e: 'close'): void
(e: 'expense-submitted'): void
(e: 'action-complete'): void
}
const emit = defineEmits<Emits>()
// Inject services
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
// Component state
const currentStep = ref(1)
const selectedAccount = ref<Account | null>(null)
const isSubmitting = ref(false)
// Form schema
const formSchema = toTypedSchema(
z.object({
description: z.string().min(1, 'Description is required').max(500, 'Description too long'),
amount: z.coerce.number().min(1, 'Amount must be at least 1 sat'),
reference: z.string().max(100, 'Reference too long').optional(),
isEquity: z.boolean().default(false)
})
)
// Set up form
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
amount: 0,
reference: '',
isEquity: false
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
/**
* Handle account selection
*/
function handleAccountSelected(account: Account) {
selectedAccount.value = account
currentStep.value = 2
}
/**
* Submit expense
*/
const onSubmit = form.handleSubmit(async (values) => {
if (!selectedAccount.value) {
console.error('[AddExpense] No account selected')
return
}
isSubmitting.value = true
try {
await expensesAPI.submitExpense({
description: values.description,
amount: values.amount,
expense_account: selectedAccount.value.name,
is_equity: values.isEquity,
user_wallet: '', // Will be filled by backend from auth
reference: values.reference,
currency: 'sats'
})
// Reset form and close
resetForm()
selectedAccount.value = null
currentStep.value = 1
emit('expense-submitted')
emit('action-complete')
emit('close')
} catch (error) {
console.error('[AddExpense] Error submitting expense:', error)
} finally {
isSubmitting.value = false
}
})
</script>

View file

@ -0,0 +1,58 @@
/**
* Expenses Module
*
* Provides expense tracking and submission functionality
* integrated with castle LNbits extension.
*/
import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { ExpensesAPI } from './services/ExpensesAPI'
import AddExpense from './components/AddExpense.vue'
export const expensesModule: ModulePlugin = {
name: 'expenses',
version: '1.0.0',
dependencies: ['base'],
quickActions: [
{
id: 'add-expense',
label: 'Expense',
icon: 'DollarSign',
component: markRaw(AddExpense),
category: 'finance',
order: 10,
requiresAuth: true
}
],
async install(app: App) {
console.log('[Expenses Module] Installing...')
// 1. Create and register service
const expensesAPI = new ExpensesAPI()
container.provide(SERVICE_TOKENS.EXPENSES_API, expensesAPI)
// 2. Initialize service (wait for dependencies)
await expensesAPI.initialize({
waitForDependencies: true,
maxRetries: 3,
retryDelay: 1000
})
console.log('[Expenses Module] ExpensesAPI initialized')
// 3. Register components globally (optional, for use outside quick actions)
app.component('AddExpense', AddExpense)
console.log('[Expenses Module] Installed successfully')
}
}
export default expensesModule
// Export types for use in other modules
export type { Account, AccountNode, ExpenseEntry, ExpenseEntryRequest } from './types'

View file

@ -0,0 +1,248 @@
/**
* API service for castle extension expense operations
*/
import { BaseService } from '@/core/base/BaseService'
import type {
Account,
ExpenseEntryRequest,
ExpenseEntry,
AccountNode
} from '../types'
import { appConfig } from '@/app.config'
export class ExpensesAPI extends BaseService {
protected readonly metadata = {
name: 'ExpensesAPI',
version: '1.0.0',
dependencies: ['AuthService', 'ToastService']
}
private get config() {
return appConfig.modules.expenses.config
}
private get baseUrl() {
return this.config?.apiConfig?.baseUrl || ''
}
protected async onInitialize(): Promise<void> {
console.log('[ExpensesAPI] Initialized with config:', {
baseUrl: this.baseUrl,
timeout: this.config?.apiConfig?.timeout
})
}
/**
* Get authentication headers
*/
private getHeaders(): HeadersInit {
if (!this.authService) {
throw new Error('AuthService not available')
}
const walletKey = this.authService.walletKey
if (!walletKey) {
throw new Error('Wallet key not available. Please log in.')
}
return {
'Content-Type': 'application/json',
'X-Api-Key': walletKey
}
}
/**
* Get all accounts from castle
*
* Note: Currently returns all accounts. Once castle API implements
* user permissions, use filter_by_user=true parameter.
*/
async getAccounts(): Promise<Account[]> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/accounts`, {
method: 'GET',
headers: this.getHeaders(),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch accounts: ${response.statusText}`)
}
const accounts = await response.json()
return accounts as Account[]
} catch (error) {
console.error('[ExpensesAPI] Error fetching accounts:', error)
if (this.toastService) {
this.toastService.error('Failed to load accounts', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
throw error
}
}
/**
* Get accounts in hierarchical tree structure
*
* Converts flat account list to nested tree based on colon-separated names
* e.g., "Expenses:Groceries:Organic" becomes nested structure
*/
async getAccountHierarchy(rootAccount?: string): Promise<AccountNode[]> {
const accounts = await this.getAccounts()
// Filter by root account if specified
let filteredAccounts = accounts
if (rootAccount) {
filteredAccounts = accounts.filter(
(acc) =>
acc.name === rootAccount || acc.name.startsWith(`${rootAccount}:`)
)
}
// Build hierarchy
const accountMap = new Map<string, AccountNode>()
// First pass: Create nodes for all accounts
for (const account of filteredAccounts) {
const parts = account.name.split(':')
const level = parts.length - 1
const parentName = parts.slice(0, -1).join(':')
accountMap.set(account.name, {
account: {
...account,
level,
parent_account: parentName || undefined,
has_children: false
},
children: []
})
}
// Second pass: Build parent-child relationships
const rootNodes: AccountNode[] = []
for (const [_name, node] of accountMap.entries()) {
const parentName = node.account.parent_account
if (parentName && accountMap.has(parentName)) {
// Add to parent's children
const parent = accountMap.get(parentName)!
parent.children.push(node)
parent.account.has_children = true
} else {
// Root level node
rootNodes.push(node)
}
}
// Sort children by name at each level
const sortNodes = (nodes: AccountNode[]) => {
nodes.sort((a, b) => a.account.name.localeCompare(b.account.name))
nodes.forEach((node) => sortNodes(node.children))
}
sortNodes(rootNodes)
return rootNodes
}
/**
* Submit expense entry to castle
*/
async submitExpense(request: ExpenseEntryRequest): Promise<ExpenseEntry> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/entries/expense`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to submit expense: ${response.statusText}`
throw new Error(errorMessage)
}
const entry = await response.json()
if (this.toastService) {
this.toastService.success('Expense submitted', {
description: 'Your expense is pending admin approval'
})
}
return entry as ExpenseEntry
} catch (error) {
console.error('[ExpensesAPI] Error submitting expense:', error)
let errorMessage = 'Failed to submit expense'
if (error instanceof Error) {
errorMessage = error.message
}
if (this.toastService) {
this.toastService.error('Submission failed', {
description: errorMessage
})
}
throw error
}
}
/**
* Get user's expense entries
*/
async getUserExpenses(): Promise<ExpenseEntry[]> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/entries/user`, {
method: 'GET',
headers: this.getHeaders(),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(
`Failed to fetch user expenses: ${response.statusText}`
)
}
const entries = await response.json()
return entries as ExpenseEntry[]
} catch (error) {
console.error('[ExpensesAPI] Error fetching user expenses:', error)
if (this.toastService) {
this.toastService.error('Failed to load expenses', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
throw error
}
}
/**
* Get user's balance with castle
*/
async getUserBalance(): Promise<{ balance: number; currency: string }> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/balance`, {
method: 'GET',
headers: this.getHeaders(),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch balance: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching balance:', error)
throw error
}
}
}

View file

@ -0,0 +1,97 @@
/**
* Types for the Expenses module
*/
/**
* Account types in the castle double-entry accounting system
*/
export enum AccountType {
ASSET = 'asset',
LIABILITY = 'liability',
EQUITY = 'equity',
REVENUE = 'revenue',
EXPENSE = 'expense'
}
/**
* Account with hierarchical structure
*/
export interface Account {
id: string
name: string // e.g., "Expenses:Groceries:Organic"
account_type: AccountType
description?: string
user_id?: string
// Hierarchical metadata (added by frontend)
parent_account?: string
level?: number
has_children?: boolean
}
/**
* Account with user-specific permission metadata
* (Will be available once castle API implements permissions)
*/
export interface AccountWithPermissions extends Account {
user_permissions?: PermissionType[]
inherited_from?: string
}
/**
* Permission types for account access control
*/
export enum PermissionType {
READ = 'read',
SUBMIT_EXPENSE = 'submit_expense',
MANAGE = 'manage'
}
/**
* Expense entry request payload
*/
export interface ExpenseEntryRequest {
description: string
amount: number // In sats
expense_account: string // Account name or ID
is_equity: boolean
user_wallet: string
reference?: string
currency?: string // e.g., "USD"
entry_date?: string // ISO datetime string
}
/**
* Expense entry response from castle API
*/
export interface ExpenseEntry {
id: string
journal_id: string
description: string
amount: number
expense_account: string
is_equity: boolean
user_wallet: string
reference?: string
currency?: string
entry_date: string
created_at: string
status: 'pending' | 'approved' | 'rejected' | 'void'
}
/**
* Hierarchical account tree node for UI rendering
*/
export interface AccountNode {
account: Account
children: AccountNode[]
}
/**
* Module configuration
*/
export interface ExpensesConfig {
apiConfig: {
baseUrl: string
timeout: number
}
}