/** * 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: [] // 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 */ async getAccounts(walletKey: string, filterByUser: boolean = false): Promise { try { const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`) if (filterByUser) { url.searchParams.set('filter_by_user', '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 */ async getAccountHierarchy( walletKey: string, rootAccount?: string, filterByUser: boolean = false ): Promise { const accounts = await this.getAccounts(walletKey, filterByUser) // 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 } } }