Add expense tracking system with receipt management and currency conversion
- Add expense list and detail views with filtering capabilities - Implement receipt image viewer and PDF export functionality - Add currency conversion support with automatic rate updates - Create API endpoints for expense CRUD operations - Integrate with NocoDB for expense data persistence - Add expense menu item to dashboard navigation
This commit is contained in:
35
server/api/currency/refresh.ts
Normal file
35
server/api/currency/refresh.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { requireAuth } from '@/server/utils/auth';
|
||||
import { refreshExchangeRates } from '@/server/utils/currency';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
|
||||
console.log('[currency/refresh] Manual exchange rate refresh requested');
|
||||
|
||||
try {
|
||||
const result = await refreshExchangeRates();
|
||||
|
||||
if (result.success) {
|
||||
console.log('[currency/refresh] Exchange rates refreshed successfully');
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
ratesCount: result.ratesCount,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} else {
|
||||
console.error('[currency/refresh] Failed to refresh exchange rates:', result.message);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: result.message
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[currency/refresh] Error during refresh:', error);
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to refresh exchange rates'
|
||||
});
|
||||
}
|
||||
});
|
||||
26
server/api/currency/status.ts
Normal file
26
server/api/currency/status.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { requireAuth } from '@/server/utils/auth';
|
||||
import { getCacheStatus } from '@/server/utils/currency';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
|
||||
console.log('[currency/status] Cache status requested');
|
||||
|
||||
try {
|
||||
const status = await getCacheStatus();
|
||||
|
||||
console.log('[currency/status] Cache status:', status);
|
||||
|
||||
return {
|
||||
...status,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[currency/status] Error getting cache status:', error);
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to get cache status'
|
||||
});
|
||||
}
|
||||
});
|
||||
163
server/api/currency/test.ts
Normal file
163
server/api/currency/test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { requireAuth } from '@/server/utils/auth';
|
||||
import {
|
||||
convertToUSD,
|
||||
getCurrencySymbol,
|
||||
formatPriceWithCurrency,
|
||||
createDisplayPrice,
|
||||
getExchangeRates,
|
||||
processExpenseWithCurrency
|
||||
} from '@/server/utils/currency';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
|
||||
console.log('[currency/test] Running currency conversion tests...');
|
||||
|
||||
try {
|
||||
const testResults = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tests: [] as any[]
|
||||
};
|
||||
|
||||
// Test 1: Get exchange rates
|
||||
console.log('[currency/test] Test 1: Getting exchange rates...');
|
||||
const rates = await getExchangeRates();
|
||||
testResults.tests.push({
|
||||
name: 'Get Exchange Rates',
|
||||
success: !!rates,
|
||||
data: rates ? {
|
||||
cached: true,
|
||||
ratesCount: Object.keys(rates.rates).length,
|
||||
lastUpdated: rates.lastUpdated,
|
||||
sampleRates: {
|
||||
EUR: rates.rates.EUR,
|
||||
GBP: rates.rates.GBP,
|
||||
JPY: rates.rates.JPY
|
||||
}
|
||||
} : null
|
||||
});
|
||||
|
||||
// Test 2: Currency symbol mapping
|
||||
console.log('[currency/test] Test 2: Testing currency symbols...');
|
||||
const symbolTests = ['EUR', 'USD', 'GBP', 'JPY', 'CHF'];
|
||||
const symbols = symbolTests.map(code => ({
|
||||
code,
|
||||
symbol: getCurrencySymbol(code)
|
||||
}));
|
||||
testResults.tests.push({
|
||||
name: 'Currency Symbols',
|
||||
success: true,
|
||||
data: symbols
|
||||
});
|
||||
|
||||
// Test 3: Currency conversion
|
||||
console.log('[currency/test] Test 3: Testing currency conversion...');
|
||||
const conversionTests = [
|
||||
{ amount: 100, from: 'EUR' },
|
||||
{ amount: 50, from: 'GBP' },
|
||||
{ amount: 1000, from: 'JPY' },
|
||||
{ amount: 100, from: 'USD' } // Should be 1:1 conversion
|
||||
];
|
||||
|
||||
const conversions = [];
|
||||
for (const test of conversionTests) {
|
||||
try {
|
||||
const result = await convertToUSD(test.amount, test.from);
|
||||
conversions.push({
|
||||
original: `${test.amount} ${test.from}`,
|
||||
result: result ? {
|
||||
usdAmount: result.usdAmount,
|
||||
rate: result.rate,
|
||||
formatted: formatPriceWithCurrency(result.usdAmount, 'USD')
|
||||
} : null,
|
||||
success: !!result
|
||||
});
|
||||
} catch (error) {
|
||||
conversions.push({
|
||||
original: `${test.amount} ${test.from}`,
|
||||
result: null,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
testResults.tests.push({
|
||||
name: 'Currency Conversions',
|
||||
success: conversions.every(c => c.success),
|
||||
data: conversions
|
||||
});
|
||||
|
||||
// Test 4: Display price formatting
|
||||
console.log('[currency/test] Test 4: Testing display price formatting...');
|
||||
const displayTests = [
|
||||
{ amount: 45.99, currency: 'EUR', usd: 48.12 },
|
||||
{ amount: 100, currency: 'USD' }, // No USD conversion needed
|
||||
{ amount: 85.50, currency: 'GBP', usd: 103.25 }
|
||||
];
|
||||
|
||||
const displayPrices = displayTests.map(test => ({
|
||||
original: { amount: test.amount, currency: test.currency },
|
||||
display: createDisplayPrice(test.amount, test.currency, test.usd),
|
||||
formatted: formatPriceWithCurrency(test.amount, test.currency)
|
||||
}));
|
||||
testResults.tests.push({
|
||||
name: 'Display Price Formatting',
|
||||
success: true,
|
||||
data: displayPrices
|
||||
});
|
||||
|
||||
// Test 5: Full expense processing
|
||||
console.log('[currency/test] Test 5: Testing full expense processing...');
|
||||
const mockExpense = {
|
||||
Id: 999,
|
||||
'Establishment Name': 'Test Restaurant',
|
||||
Price: '45.99',
|
||||
currency: 'EUR',
|
||||
'Payment Method': 'Card',
|
||||
Category: 'Food/Drinks',
|
||||
Payer: 'Test User',
|
||||
Time: new Date().toISOString(),
|
||||
Contents: 'Test expense for currency conversion',
|
||||
Receipt: [],
|
||||
Paid: false,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const processedExpense = await processExpenseWithCurrency(mockExpense);
|
||||
testResults.tests.push({
|
||||
name: 'Full Expense Processing',
|
||||
success: !!(processedExpense.PriceUSD && processedExpense.DisplayPrice),
|
||||
data: {
|
||||
original: mockExpense,
|
||||
processed: {
|
||||
PriceNumber: processedExpense.PriceNumber,
|
||||
CurrencySymbol: processedExpense.CurrencySymbol,
|
||||
PriceUSD: processedExpense.PriceUSD,
|
||||
ConversionRate: processedExpense.ConversionRate,
|
||||
DisplayPrice: processedExpense.DisplayPrice,
|
||||
DisplayPriceUSD: processedExpense.DisplayPriceUSD
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate overall success
|
||||
const overallSuccess = testResults.tests.every(test => test.success);
|
||||
|
||||
console.log(`[currency/test] Tests completed. Overall success: ${overallSuccess}`);
|
||||
|
||||
return {
|
||||
success: overallSuccess,
|
||||
message: overallSuccess ? 'All currency tests passed' : 'Some currency tests failed',
|
||||
results: testResults
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[currency/test] Error during currency tests:', error);
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Currency test failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
53
server/api/get-expense-by-id.ts
Normal file
53
server/api/get-expense-by-id.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from '@/server/utils/auth';
|
||||
import { getExpenseById } from '@/server/utils/nocodb';
|
||||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
|
||||
const query = getQuery(event);
|
||||
const { id } = query;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Expense ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[get-expense-by-id] Fetching expense ID:', id);
|
||||
|
||||
try {
|
||||
const expense = await getExpenseById(id);
|
||||
|
||||
// Process expense with currency conversion
|
||||
const processedExpense = await processExpenseWithCurrency(expense);
|
||||
|
||||
// Transform the response to include additional computed data
|
||||
const transformedExpense = {
|
||||
...processedExpense,
|
||||
// Format the date for easier frontend consumption
|
||||
FormattedDate: new Date(expense.Time).toLocaleDateString(),
|
||||
FormattedTime: new Date(expense.Time).toLocaleTimeString(),
|
||||
FormattedDateTime: new Date(expense.Time).toLocaleString()
|
||||
};
|
||||
|
||||
console.log('[get-expense-by-id] Successfully fetched expense:', transformedExpense.Id);
|
||||
|
||||
return transformedExpense;
|
||||
} catch (error: any) {
|
||||
console.error('[get-expense-by-id] Error fetching expense:', error);
|
||||
|
||||
if (error.statusCode === 404 || error.status === 404) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `Expense with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch expense'
|
||||
});
|
||||
}
|
||||
});
|
||||
78
server/api/get-expenses.ts
Normal file
78
server/api/get-expenses.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { requireAuth } from '@/server/utils/auth';
|
||||
import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
|
||||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||
import type { ExpenseFilters } from '@/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
|
||||
const query = getQuery(event);
|
||||
|
||||
// If no date filters provided, default to current month
|
||||
if (!query.startDate && !query.endDate) {
|
||||
console.log('[get-expenses] No date filters provided, defaulting to current month');
|
||||
const result = await getCurrentMonthExpenses();
|
||||
|
||||
// Process expenses with currency conversion
|
||||
const processedExpenses = await Promise.all(
|
||||
result.list.map(expense => processExpenseWithCurrency(expense))
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
list: processedExpenses
|
||||
};
|
||||
}
|
||||
|
||||
// Build filters from query parameters
|
||||
const filters: ExpenseFilters = {};
|
||||
|
||||
if (query.startDate && typeof query.startDate === 'string') {
|
||||
filters.startDate = query.startDate;
|
||||
}
|
||||
|
||||
if (query.endDate && typeof query.endDate === 'string') {
|
||||
filters.endDate = query.endDate;
|
||||
}
|
||||
|
||||
if (query.payer && typeof query.payer === 'string') {
|
||||
filters.payer = query.payer;
|
||||
}
|
||||
|
||||
if (query.category && typeof query.category === 'string') {
|
||||
filters.category = query.category as any; // Cast to ExpenseCategory
|
||||
}
|
||||
|
||||
console.log('[get-expenses] Fetching expenses with filters:', filters);
|
||||
|
||||
const result = await getExpenses(filters);
|
||||
|
||||
// Process expenses with currency conversion
|
||||
const processedExpenses = await Promise.all(
|
||||
result.list.map(expense => processExpenseWithCurrency(expense))
|
||||
);
|
||||
|
||||
// Add formatted dates
|
||||
const transformedExpenses = processedExpenses.map(expense => ({
|
||||
...expense,
|
||||
FormattedDate: new Date(expense.Time).toLocaleDateString(),
|
||||
FormattedTime: new Date(expense.Time).toLocaleTimeString()
|
||||
}));
|
||||
|
||||
// Calculate summary with USD totals
|
||||
const usdTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceUSD || e.PriceNumber || 0), 0);
|
||||
const originalTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceNumber || 0), 0);
|
||||
|
||||
return {
|
||||
expenses: transformedExpenses,
|
||||
PageInfo: result.PageInfo,
|
||||
totalCount: result.PageInfo?.totalRows || transformedExpenses.length,
|
||||
summary: {
|
||||
total: originalTotal, // Original currency total (mixed currencies)
|
||||
totalUSD: usdTotal, // USD converted total
|
||||
count: transformedExpenses.length,
|
||||
uniquePayers: [...new Set(transformedExpenses.map(e => e.Payer))].length,
|
||||
currencies: [...new Set(transformedExpenses.map(e => e.currency))].filter(Boolean)
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user