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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue