Ensures that the account selector only displays accounts that the user has permissions for. This change modifies the `ExpensesAPI` to include a `filterByUser` parameter when fetching accounts, which is then passed to the backend to retrieve only authorized accounts. A log statement was added to confirm proper filtering.
266 lines
7.5 KiB
TypeScript
266 lines
7.5 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<Account[]> {
|
|
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<AccountNode[]> {
|
|
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<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(walletKey: string, request: ExpenseEntryRequest): Promise<ExpenseEntry> {
|
|
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<ExpenseEntry[]> {
|
|
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<string[]> {
|
|
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<string | null> {
|
|
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
|
|
}
|
|
}
|
|
}
|