web-app/src/modules/expenses/services/ExpensesAPI.ts
padreug 84596e518e Update TransactionsPage to match castle extension date range API changes
Synchronized TransactionsPage with castle LNbits extension API updates that introduced custom date range filtering.

**API Changes (ExpensesAPI.ts):**
- Updated `getUserTransactions()` to support `start_date` and `end_date` parameters (YYYY-MM-DD format)
- Custom date range takes precedence over preset days parameter
- Updated comment: default changed from 5 to 15 days, options now 15/30/60 (removed 5 and 90)

**UI Changes (TransactionsPage.vue):**
- **New defaults**: Changed default from 5 days to 15 days
- **New preset options**: 15, 30, 60 days (removed 5 and 90 day options)
- **Custom date range**: Added "Custom" option with date picker inputs
  - From/To date inputs using native HTML5 date picker
  - Apply button to load transactions for custom range
  - Auto-clears custom dates when switching back to preset days
- **State management**:
  - `dateRangeType` ref supports both numbers (15, 30, 60) and 'custom' string
  - `customStartDate` and `customEndDate` refs for custom date range
- **Smart loading**: Only loads transactions after user provides both dates and clicks Apply

**Priority Logic:**
- Custom date range (start_date + end_date) takes precedence
- Falls back to preset days if custom not selected
- Defaults to 15 days if custom selected but dates not provided

Matches castle extension implementation exactly (see castle extension git diff in fava_client.py, views_api.py, and static/js/index.js).

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:38:34 +01:00

450 lines
13 KiB
TypeScript

/**
* 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<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
* @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
*/
async getAccounts(
walletKey: string,
filterByUser: boolean = false,
excludeVirtual: boolean = true
): Promise<Account[]> {
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<AccountNode[]> {
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<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
}
}
/**
* Get user information including equity eligibility
*
* @param walletKey - Wallet key for authentication (invoice key)
*/
async getUserInfo(walletKey: string): Promise<UserInfo> {
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<AccountPermission[]> {
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<AccountPermission> {
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<void> {
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<TransactionListResponse> {
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
}
}
}