/** * API service for castle extension expense operations */ import { BaseService } from '@/core/base/BaseService' import type { Account, ExpenseEntryRequest, ExpenseEntry, AccountNode, UserInfo, AccountPermission, GrantPermissionRequest, TransactionListResponse } from '../types' import { appConfig } from '@/app.config' export class ExpensesAPI extends BaseService { protected readonly metadata = { name: 'ExpensesAPI', version: '1.0.0', dependencies: [] // No dependencies - wallet key is passed as parameter } 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 with provided wallet key */ private getHeaders(walletKey: string): HeadersInit { return { 'Content-Type': 'application/json', 'X-Api-Key': walletKey } } /** * Get all accounts from castle * * @param walletKey - Wallet key for authentication * @param filterByUser - If true, only return accounts the user has permissions for * @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views) */ async getAccounts( walletKey: string, filterByUser: boolean = false, excludeVirtual: boolean = true ): Promise { try { const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`) if (filterByUser) { url.searchParams.set('filter_by_user', 'true') } if (excludeVirtual) { url.searchParams.set('exclude_virtual', 'true') } const response = await fetch(url.toString(), { method: 'GET', headers: this.getHeaders(walletKey), 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) 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 * * @param walletKey - Wallet key for authentication * @param rootAccount - Optional root account to filter by (e.g., "Expenses") * @param filterByUser - If true, only return accounts the user has permissions for * @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views) */ async getAccountHierarchy( walletKey: string, rootAccount?: string, filterByUser: boolean = false, excludeVirtual: boolean = true ): Promise { const accounts = await this.getAccounts(walletKey, filterByUser, excludeVirtual) // 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(walletKey: string, request: ExpenseEntryRequest): Promise { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/expense`, { method: 'POST', headers: this.getHeaders(walletKey), 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() return entry as ExpenseEntry } catch (error) { console.error('[ExpensesAPI] Error submitting expense:', error) throw error } } /** * Get user's expense entries */ async getUserExpenses(walletKey: string): Promise { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/user`, { method: 'GET', headers: this.getHeaders(walletKey), 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) throw error } } /** * Get user's balance with castle */ async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/balance`, { method: 'GET', headers: this.getHeaders(walletKey), 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 } } /** * Get available currencies from LNbits instance */ async getCurrencies(): Promise { try { const response = await fetch(`${this.baseUrl}/api/v1/currencies`, { method: 'GET', signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) }) if (!response.ok) { throw new Error(`Failed to fetch currencies: ${response.statusText}`) } return await response.json() } catch (error) { console.error('[ExpensesAPI] Error fetching currencies:', error) throw error } } /** * Get default currency from LNbits instance */ async getDefaultCurrency(): Promise { try { const response = await fetch(`${this.baseUrl}/api/v1/default-currency`, { method: 'GET', signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) }) if (!response.ok) { throw new Error(`Failed to fetch default currency: ${response.statusText}`) } const data = await response.json() return data.default_currency } catch (error) { console.error('[ExpensesAPI] Error fetching default currency:', error) throw error } } /** * Get user information including equity eligibility * * @param walletKey - Wallet key for authentication (invoice key) */ async getUserInfo(walletKey: string): Promise { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/user/info`, { method: 'GET', headers: this.getHeaders(walletKey), signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) }) if (!response.ok) { throw new Error(`Failed to fetch user info: ${response.statusText}`) } return await response.json() } catch (error) { console.error('[ExpensesAPI] Error fetching user info:', error) // Return default non-eligible user on error return { user_id: '', is_equity_eligible: false } } } /** * List all account permissions (admin only) * * @param adminKey - Admin key for authentication */ async listPermissions(adminKey: string): Promise { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, { method: 'GET', headers: this.getHeaders(adminKey), signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) }) if (!response.ok) { throw new Error(`Failed to list permissions: ${response.statusText}`) } const permissions = await response.json() return permissions as AccountPermission[] } catch (error) { console.error('[ExpensesAPI] Error listing permissions:', error) throw error } } /** * Grant account permission to a user (admin only) * * @param adminKey - Admin key for authentication * @param request - Permission grant request */ async grantPermission( adminKey: string, request: GrantPermissionRequest ): Promise { try { const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, { method: 'POST', headers: this.getHeaders(adminKey), 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 grant permission: ${response.statusText}` throw new Error(errorMessage) } const permission = await response.json() return permission as AccountPermission } catch (error) { console.error('[ExpensesAPI] Error granting permission:', error) throw error } } /** * Revoke account permission (admin only) * * @param adminKey - Admin key for authentication * @param permissionId - ID of the permission to revoke */ async revokePermission(adminKey: string, permissionId: string): Promise { try { const response = await fetch( `${this.baseUrl}/castle/api/v1/permissions/${permissionId}`, { method: 'DELETE', headers: this.getHeaders(adminKey), signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) } ) if (!response.ok) { const errorData = await response.json().catch(() => ({})) const errorMessage = errorData.detail || `Failed to revoke permission: ${response.statusText}` throw new Error(errorMessage) } } catch (error) { console.error('[ExpensesAPI] Error revoking permission:', error) throw error } } /** * Get user's transactions from journal * * @param walletKey - Wallet key for authentication (invoice key) * @param options - Query options for filtering and pagination */ async getUserTransactions( walletKey: string, options?: { limit?: number offset?: number days?: number // 15, 30, or 60 (default: 15) start_date?: string // ISO format: YYYY-MM-DD end_date?: string // ISO format: YYYY-MM-DD filter_user_id?: string filter_account_type?: string } ): Promise { try { const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`) // Add query parameters if (options?.limit) url.searchParams.set('limit', String(options.limit)) if (options?.offset) url.searchParams.set('offset', String(options.offset)) // Custom date range takes precedence over days if (options?.start_date && options?.end_date) { url.searchParams.set('start_date', options.start_date) url.searchParams.set('end_date', options.end_date) } else if (options?.days) { url.searchParams.set('days', String(options.days)) } if (options?.filter_user_id) url.searchParams.set('filter_user_id', options.filter_user_id) if (options?.filter_account_type) url.searchParams.set('filter_account_type', options.filter_account_type) const response = await fetch(url.toString(), { method: 'GET', headers: this.getHeaders(walletKey), signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000) }) if (!response.ok) { throw new Error(`Failed to fetch transactions: ${response.statusText}`) } return await response.json() } catch (error) { console.error('[ExpensesAPI] Error fetching transactions:', error) throw error } } }