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>
450 lines
13 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|