diff --git a/src/app.config.ts b/src/app.config.ts index 8060762..8893996 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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 + } } }, diff --git a/src/app.ts b/src/app.ts index 459283e..6878880 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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) diff --git a/src/core/di-container.ts b/src/core/di-container.ts index da71624..0d27524 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -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 diff --git a/src/modules/expenses/components/AccountSelector.vue b/src/modules/expenses/components/AccountSelector.vue new file mode 100644 index 0000000..ec49ed3 --- /dev/null +++ b/src/modules/expenses/components/AccountSelector.vue @@ -0,0 +1,257 @@ + + + + + + + Start + + + + + + {{ getAccountDisplayName(node.account.name) }} + + + + + + + + + + + + Loading accounts... + + + + + + {{ error }} + + + Retry + + + + + + + + + + + + {{ getAccountDisplayName(node.account.name) }} + + + {{ node.account.description }} + + + + + + {{ node.account.account_type }} + + + + + + + + + + No accounts available + + + + + + + + + Selected Account + {{ selectedAccount.name }} + + + {{ selectedAccount.account_type }} + + + + + diff --git a/src/modules/expenses/components/AddExpense.vue b/src/modules/expenses/components/AddExpense.vue new file mode 100644 index 0000000..6b24130 --- /dev/null +++ b/src/modules/expenses/components/AddExpense.vue @@ -0,0 +1,288 @@ + + + + + + + Add Expense + + + + + + + + + + + + 1 + + + + 2 + + + + + + + Select the account for this expense + + + + + + + + + + + Description * + + + + + Describe what this expense was for + + + + + + + + + Amount (sats) * + + + + + Amount in satoshis + + + + + + + + + Reference + + + + + Optional reference number or note + + + + + + + + + + + + + + Account from equity + + Check if this expense should be accounted from your equity + + + + + + + + + + Account: {{ selectedAccount?.name }} + + + {{ selectedAccount.description }} + + + + + + + + Back + + + + {{ isSubmitting ? 'Submitting...' : 'Submit Expense' }} + + + + + + + + + diff --git a/src/modules/expenses/index.ts b/src/modules/expenses/index.ts new file mode 100644 index 0000000..8e21d18 --- /dev/null +++ b/src/modules/expenses/index.ts @@ -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' diff --git a/src/modules/expenses/services/ExpensesAPI.ts b/src/modules/expenses/services/ExpensesAPI.ts new file mode 100644 index 0000000..0646bc7 --- /dev/null +++ b/src/modules/expenses/services/ExpensesAPI.ts @@ -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 { + 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 { + 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 { + 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() + + // 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 { + 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 { + 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 + } + } +} diff --git a/src/modules/expenses/types/index.ts b/src/modules/expenses/types/index.ts new file mode 100644 index 0000000..04a4c97 --- /dev/null +++ b/src/modules/expenses/types/index.ts @@ -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 + } +}
{{ error }}
+ {{ getAccountDisplayName(node.account.name) }} +
+ {{ node.account.description }} +
No accounts available
Selected Account
{{ selectedAccount.name }}
+ Select the account for this expense +
+ Account: {{ selectedAccount?.name }} +
+ {{ selectedAccount.description }} +