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:
parent
678ccff694
commit
9ed674d0f3
8 changed files with 975 additions and 1 deletions
257
src/modules/expenses/components/AccountSelector.vue
Normal file
257
src/modules/expenses/components/AccountSelector.vue
Normal 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>
|
||||
288
src/modules/expenses/components/AddExpense.vue
Normal file
288
src/modules/expenses/components/AddExpense.vue
Normal 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>
|
||||
58
src/modules/expenses/index.ts
Normal file
58
src/modules/expenses/index.ts
Normal 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'
|
||||
248
src/modules/expenses/services/ExpensesAPI.ts
Normal file
248
src/modules/expenses/services/ExpensesAPI.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/modules/expenses/types/index.ts
Normal file
97
src/modules/expenses/types/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue