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)
|
||||
}
|
||||
};
|
||||
});
|
||||
30
server/tasks/currency-refresh.ts
Normal file
30
server/tasks/currency-refresh.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { refreshExchangeRates } from '@/server/utils/currency';
|
||||
|
||||
/**
|
||||
* Scheduled task to refresh currency exchange rates hourly
|
||||
* This should be called by a cron job or scheduled task runner
|
||||
*/
|
||||
export const refreshCurrencyRatesTask = async () => {
|
||||
try {
|
||||
console.log('[currency-refresh-task] Starting scheduled currency refresh...');
|
||||
|
||||
const result = await refreshExchangeRates();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[currency-refresh-task] Successfully refreshed ${result.ratesCount} exchange rates`);
|
||||
} else {
|
||||
console.error('[currency-refresh-task] Failed to refresh exchange rates:', result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[currency-refresh-task] Error during scheduled refresh:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Error occurred during scheduled refresh'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// For environments that support direct cron scheduling
|
||||
export default refreshCurrencyRatesTask;
|
||||
446
server/utils/currency.ts
Normal file
446
server/utils/currency.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// Currency symbol mapping from ISO codes to display symbols
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
// Major World Currencies
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
JPY: '¥',
|
||||
CHF: 'Fr',
|
||||
CAD: 'C$',
|
||||
AUD: 'A$',
|
||||
CNY: '¥',
|
||||
|
||||
// European Currencies
|
||||
SEK: 'kr', // Swedish Krona
|
||||
NOK: 'kr', // Norwegian Krone
|
||||
DKK: 'kr', // Danish Krone
|
||||
ISK: 'kr', // Icelandic Krona
|
||||
PLN: 'zł', // Polish Złoty
|
||||
CZK: 'Kč', // Czech Koruna
|
||||
HUF: 'Ft', // Hungarian Forint
|
||||
RON: 'lei', // Romanian Leu
|
||||
BGN: 'лв', // Bulgarian Lev
|
||||
HRK: 'kn', // Croatian Kuna
|
||||
RUB: '₽', // Russian Ruble
|
||||
TRY: '₺', // Turkish Lira
|
||||
ALL: 'L', // Albanian Lek
|
||||
BAM: 'KM', // Bosnia and Herzegovina Convertible Mark
|
||||
MKD: 'ден', // Macedonian Denar
|
||||
RSD: 'дин', // Serbian Dinar
|
||||
MDL: 'L', // Moldovan Leu
|
||||
UAH: '₴', // Ukrainian Hryvnia
|
||||
BYN: 'Br', // Belarusian Ruble
|
||||
GEL: '₾', // Georgian Lari
|
||||
AMD: '֏', // Armenian Dram
|
||||
AZN: '₼', // Azerbaijani Manat
|
||||
|
||||
// Caribbean Currencies
|
||||
XCD: 'EC$', // Eastern Caribbean Dollar (Antigua, Dominica, Grenada, etc.)
|
||||
BBD: 'Bds$', // Barbados Dollar
|
||||
BSD: 'B$', // Bahamian Dollar
|
||||
BZD: 'BZ$', // Belize Dollar
|
||||
JMD: 'J$', // Jamaican Dollar
|
||||
KYD: 'CI$', // Cayman Islands Dollar
|
||||
TTD: 'TT$', // Trinidad and Tobago Dollar
|
||||
CUP: '₱', // Cuban Peso
|
||||
CUC: 'CUC$', // Cuban Convertible Peso
|
||||
DOP: 'RD$', // Dominican Peso
|
||||
HTG: 'G', // Haitian Gourde
|
||||
AWG: 'ƒ', // Aruban Florin
|
||||
ANG: 'ƒ', // Netherlands Antillean Guilder
|
||||
SRD: '$', // Suriname Dollar
|
||||
GYD: 'G$', // Guyana Dollar
|
||||
|
||||
// Central America & Panama
|
||||
PAB: 'B/.', // Panamanian Balboa
|
||||
GTQ: 'Q', // Guatemalan Quetzal
|
||||
HNL: 'L', // Honduran Lempira
|
||||
NIO: 'C$', // Nicaraguan Córdoba
|
||||
CRC: '₡', // Costa Rican Colón
|
||||
|
||||
// North America
|
||||
MXN: '$', // Mexican Peso
|
||||
|
||||
// South America
|
||||
BRL: 'R$', // Brazilian Real
|
||||
ARS: '$', // Argentine Peso
|
||||
CLP: '$', // Chilean Peso
|
||||
COP: '$', // Colombian Peso
|
||||
PEN: 'S/', // Peruvian Sol
|
||||
UYU: '$U', // Uruguayan Peso
|
||||
PYG: '₲', // Paraguayan Guaraní
|
||||
BOB: 'Bs.', // Bolivian Boliviano
|
||||
VES: 'Bs.S', // Venezuelan Bolívar Soberano
|
||||
|
||||
// Asia
|
||||
INR: '₹', // Indian Rupee
|
||||
KRW: '₩', // South Korean Won
|
||||
SGD: 'S$', // Singapore Dollar
|
||||
HKD: 'HK$', // Hong Kong Dollar
|
||||
TWD: 'NT$', // Taiwan Dollar
|
||||
THB: '฿', // Thai Baht
|
||||
MYR: 'RM', // Malaysian Ringgit
|
||||
PHP: '₱', // Philippine Peso
|
||||
IDR: 'Rp', // Indonesian Rupiah
|
||||
VND: '₫', // Vietnamese Dong
|
||||
LAK: '₭', // Lao Kip
|
||||
KHR: '៛', // Cambodian Riel
|
||||
MMK: 'K', // Myanmar Kyat
|
||||
|
||||
// Middle East & Africa
|
||||
ZAR: 'R', // South African Rand
|
||||
EGP: '£', // Egyptian Pound
|
||||
NGN: '₦', // Nigerian Naira
|
||||
KES: 'KSh', // Kenyan Shilling
|
||||
GHS: '₵', // Ghanaian Cedi
|
||||
MAD: 'د.م.', // Moroccan Dirham
|
||||
TND: 'د.ت', // Tunisian Dinar
|
||||
DZD: 'د.ج', // Algerian Dinar
|
||||
AED: 'د.إ', // UAE Dirham
|
||||
SAR: '﷼', // Saudi Riyal
|
||||
QAR: '﷼', // Qatari Riyal
|
||||
KWD: 'د.ك', // Kuwaiti Dinar
|
||||
BHD: '.د.ب', // Bahraini Dinar
|
||||
OMR: '﷼', // Omani Rial
|
||||
JOD: 'د.ا', // Jordanian Dinar
|
||||
LBP: '£', // Lebanese Pound
|
||||
ILS: '₪', // Israeli Shekel
|
||||
|
||||
// Oceania
|
||||
NZD: 'NZ$', // New Zealand Dollar
|
||||
FJD: 'FJ$', // Fijian Dollar
|
||||
TOP: 'T$', // Tongan Paʻanga
|
||||
WST: 'WS$', // Samoan Tala
|
||||
VUV: 'Vt', // Vanuatu Vatu
|
||||
SBD: 'SI$', // Solomon Islands Dollar
|
||||
PGK: 'K', // Papua New Guinea Kina
|
||||
|
||||
// Additional European Dependencies
|
||||
GIP: '£', // Gibraltar Pound
|
||||
FKP: '£', // Falkland Islands Pound
|
||||
SHP: '£', // Saint Helena Pound
|
||||
JEP: '£', // Jersey Pound
|
||||
GGP: '£', // Guernsey Pound
|
||||
IMP: '£', // Isle of Man Pound
|
||||
};
|
||||
|
||||
// Exchange rate cache interface
|
||||
interface ExchangeRateCache {
|
||||
rates: Record<string, number>;
|
||||
lastUpdated: string;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
||||
// Cache file path
|
||||
const CACHE_FILE_PATH = join(process.cwd(), '.cache', 'exchange-rates.json');
|
||||
|
||||
// Cache TTL: 1 hour in milliseconds
|
||||
const CACHE_TTL = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Get currency symbol from ISO code
|
||||
*/
|
||||
export const getCurrencySymbol = (currencyCode: string): string => {
|
||||
return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure cache directory exists
|
||||
*/
|
||||
const ensureCacheDirectory = async (): Promise<void> => {
|
||||
try {
|
||||
const cacheDir = join(process.cwd(), '.cache');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('[currency] Failed to create cache directory:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load exchange rates from cache
|
||||
*/
|
||||
const loadCachedRates = async (): Promise<ExchangeRateCache | null> => {
|
||||
try {
|
||||
const cacheData = await fs.readFile(CACHE_FILE_PATH, 'utf8');
|
||||
const cache: ExchangeRateCache = JSON.parse(cacheData);
|
||||
|
||||
// Check if cache is still valid (within TTL)
|
||||
const lastUpdated = new Date(cache.lastUpdated).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastUpdated < CACHE_TTL) {
|
||||
console.log('[currency] Using cached exchange rates');
|
||||
return cache;
|
||||
} else {
|
||||
console.log('[currency] Cache expired, need to fetch new rates');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[currency] No valid cache found, will fetch new rates');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save exchange rates to cache
|
||||
*/
|
||||
const saveCachedRates = async (cache: ExchangeRateCache): Promise<void> => {
|
||||
try {
|
||||
await ensureCacheDirectory();
|
||||
await fs.writeFile(CACHE_FILE_PATH, JSON.stringify(cache, null, 2), 'utf8');
|
||||
console.log('[currency] Exchange rates cached successfully');
|
||||
} catch (error) {
|
||||
console.error('[currency] Failed to save cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch exchange rates from Frankfurter API
|
||||
*/
|
||||
const fetchExchangeRates = async (): Promise<ExchangeRateCache | null> => {
|
||||
try {
|
||||
console.log('[currency] Fetching exchange rates from Frankfurter API...');
|
||||
|
||||
// Fetch rates with USD as base currency for consistency
|
||||
const response = await fetch('https://api.frankfurter.app/latest?from=USD');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Frankfurter API responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Frankfurter response format: { amount: 1, base: "USD", date: "2025-06-27", rates: { EUR: 0.956, ... } }
|
||||
const cache: ExchangeRateCache = {
|
||||
rates: {
|
||||
USD: 1.0, // Base currency
|
||||
...data.rates
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
baseCurrency: 'USD'
|
||||
};
|
||||
|
||||
await saveCachedRates(cache);
|
||||
console.log('[currency] Successfully fetched and cached exchange rates');
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('[currency] Failed to fetch exchange rates:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current exchange rates (cached or fresh)
|
||||
*/
|
||||
export const getExchangeRates = async (): Promise<ExchangeRateCache | null> => {
|
||||
// Try to load from cache first
|
||||
let cache = await loadCachedRates();
|
||||
|
||||
// If no valid cache, fetch fresh rates
|
||||
if (!cache) {
|
||||
cache = await fetchExchangeRates();
|
||||
}
|
||||
|
||||
return cache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert amount from one currency to USD
|
||||
*/
|
||||
export const convertToUSD = async (amount: number, fromCurrency: string): Promise<{
|
||||
usdAmount: number;
|
||||
rate: number;
|
||||
conversionDate: string;
|
||||
} | null> => {
|
||||
// If already USD, no conversion needed
|
||||
if (fromCurrency.toUpperCase() === 'USD') {
|
||||
return {
|
||||
usdAmount: amount,
|
||||
rate: 1.0,
|
||||
conversionDate: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rateCache = await getExchangeRates();
|
||||
|
||||
if (!rateCache) {
|
||||
console.error('[currency] No exchange rates available for conversion');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromCurrencyUpper = fromCurrency.toUpperCase();
|
||||
|
||||
// Get rate from source currency to USD
|
||||
// Since our cache has USD as base, we need to convert FROM the source currency TO USD
|
||||
// If USD -> EUR rate is 0.956, then EUR -> USD rate is 1/0.956
|
||||
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
|
||||
|
||||
if (!usdToSourceRate) {
|
||||
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate USD amount
|
||||
// If USD -> EUR = 0.956, then EUR -> USD = 1/0.956 = 1.046
|
||||
const sourceToUsdRate = 1 / usdToSourceRate;
|
||||
const usdAmount = amount * sourceToUsdRate;
|
||||
|
||||
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${usdAmount.toFixed(2)} USD (rate: ${sourceToUsdRate.toFixed(4)})`);
|
||||
|
||||
return {
|
||||
usdAmount: parseFloat(usdAmount.toFixed(2)),
|
||||
rate: parseFloat(sourceToUsdRate.toFixed(4)),
|
||||
conversionDate: rateCache.lastUpdated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[currency] Error during currency conversion:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format price with currency symbol
|
||||
*/
|
||||
export const formatPriceWithCurrency = (amount: number, currencyCode: string): string => {
|
||||
const symbol = getCurrencySymbol(currencyCode);
|
||||
const formattedAmount = amount.toFixed(2);
|
||||
|
||||
// For most currencies, symbol goes before the amount
|
||||
// Special cases where symbol goes after can be added here if needed
|
||||
return `${symbol}${formattedAmount}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create display price string (original + USD if not USD)
|
||||
*/
|
||||
export const createDisplayPrice = (
|
||||
originalAmount: number,
|
||||
originalCurrency: string,
|
||||
usdAmount?: number
|
||||
): string => {
|
||||
const originalFormatted = formatPriceWithCurrency(originalAmount, originalCurrency);
|
||||
|
||||
// If original currency is USD or no USD conversion available, just show original
|
||||
if (originalCurrency.toUpperCase() === 'USD' || !usdAmount) {
|
||||
return originalFormatted;
|
||||
}
|
||||
|
||||
const usdFormatted = formatPriceWithCurrency(usdAmount, 'USD');
|
||||
return `${originalFormatted} (${usdFormatted})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually refresh exchange rates (for API endpoint)
|
||||
*/
|
||||
export const refreshExchangeRates = async (): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
ratesCount?: number;
|
||||
}> => {
|
||||
try {
|
||||
console.log('[currency] Manual refresh of exchange rates requested');
|
||||
|
||||
const cache = await fetchExchangeRates();
|
||||
|
||||
if (cache) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Exchange rates refreshed successfully',
|
||||
ratesCount: Object.keys(cache.rates).length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to fetch exchange rates from API'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[currency] Error during manual refresh:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Error occurred during refresh'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cache status information
|
||||
*/
|
||||
export const getCacheStatus = async (): Promise<{
|
||||
cached: boolean;
|
||||
lastUpdated?: string;
|
||||
ratesCount?: number;
|
||||
minutesUntilExpiry?: number;
|
||||
}> => {
|
||||
try {
|
||||
const cache = await loadCachedRates();
|
||||
|
||||
if (!cache) {
|
||||
return { cached: false };
|
||||
}
|
||||
|
||||
const lastUpdated = new Date(cache.lastUpdated).getTime();
|
||||
const now = Date.now();
|
||||
const minutesUntilExpiry = Math.max(0, Math.floor((CACHE_TTL - (now - lastUpdated)) / (60 * 1000)));
|
||||
|
||||
return {
|
||||
cached: true,
|
||||
lastUpdated: cache.lastUpdated,
|
||||
ratesCount: Object.keys(cache.rates).length,
|
||||
minutesUntilExpiry
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[currency] Error checking cache status:', error);
|
||||
return { cached: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced expense processing with currency conversion
|
||||
*/
|
||||
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
|
||||
const processedExpense = { ...expense };
|
||||
|
||||
// Parse price number
|
||||
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
processedExpense.PriceNumber = priceNumber;
|
||||
|
||||
// Get currency symbol
|
||||
const currencyCode = expense.currency || 'USD';
|
||||
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
||||
|
||||
// Convert to USD if not already USD
|
||||
if (currencyCode.toUpperCase() !== 'USD') {
|
||||
const conversion = await convertToUSD(priceNumber, currencyCode);
|
||||
|
||||
if (conversion) {
|
||||
processedExpense.PriceUSD = conversion.usdAmount;
|
||||
processedExpense.ConversionRate = conversion.rate;
|
||||
processedExpense.ConversionDate = conversion.conversionDate;
|
||||
}
|
||||
} else {
|
||||
// If already USD, set USD amount to original amount
|
||||
processedExpense.PriceUSD = priceNumber;
|
||||
processedExpense.ConversionRate = 1.0;
|
||||
processedExpense.ConversionDate = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Create display prices
|
||||
processedExpense.DisplayPrice = createDisplayPrice(
|
||||
priceNumber,
|
||||
currencyCode,
|
||||
processedExpense.PriceUSD
|
||||
);
|
||||
|
||||
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
|
||||
processedExpense.PriceUSD || priceNumber,
|
||||
'USD'
|
||||
);
|
||||
|
||||
return processedExpense;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Interest, Berth } from "@/utils/types";
|
||||
import type { Interest, Berth, Expense, ExpenseFilters } from "@/utils/types";
|
||||
|
||||
export interface PageInfo {
|
||||
pageSize: number;
|
||||
@@ -18,9 +18,15 @@ export interface BerthsResponse {
|
||||
PageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export interface ExpensesResponse {
|
||||
list: Expense[];
|
||||
PageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export enum Table {
|
||||
Interest = "mbs9hjauug4eseo",
|
||||
Berth = "mczgos9hr3oa9qc",
|
||||
Expense = "mxfcefkk4dqs6uq", // Expense tracking table
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -831,3 +837,125 @@ export const updateBerth = async (id: string, data: Partial<Berth>): Promise<Ber
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Expense functions
|
||||
export const getExpenses = async (filters?: ExpenseFilters) => {
|
||||
console.log('[nocodb.getExpenses] Fetching expenses from NocoDB...', filters);
|
||||
|
||||
try {
|
||||
const params: any = { limit: 1000 };
|
||||
|
||||
// Build filter conditions
|
||||
if (filters?.startDate && filters?.endDate) {
|
||||
params.where = `(Time,gte,${filters.startDate})~and(Time,lte,${filters.endDate})`;
|
||||
} else if (filters?.startDate) {
|
||||
params.where = `(Time,gte,${filters.startDate})`;
|
||||
} else if (filters?.endDate) {
|
||||
params.where = `(Time,lte,${filters.endDate})`;
|
||||
}
|
||||
|
||||
// Add payer filter
|
||||
if (filters?.payer) {
|
||||
const payerFilter = `(Payer,eq,${filters.payer})`;
|
||||
params.where = params.where ? `${params.where}~and${payerFilter}` : payerFilter;
|
||||
}
|
||||
|
||||
// Add category filter
|
||||
if (filters?.category) {
|
||||
const categoryFilter = `(Category,eq,${filters.category})`;
|
||||
params.where = params.where ? `${params.where}~and${categoryFilter}` : categoryFilter;
|
||||
}
|
||||
|
||||
// Sort by Time descending (newest first)
|
||||
params.sort = '-Time';
|
||||
|
||||
console.log('[nocodb.getExpenses] Request params:', params);
|
||||
|
||||
const result = await $fetch<ExpensesResponse>(createTableUrl(Table.Expense), {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
params
|
||||
});
|
||||
|
||||
console.log('[nocodb.getExpenses] Successfully fetched expenses, count:', result.list?.length || 0);
|
||||
|
||||
// Transform expenses to add computed price numbers
|
||||
if (result.list && Array.isArray(result.list)) {
|
||||
result.list = result.list.map(expense => ({
|
||||
...expense,
|
||||
// Parse price string to number for calculations
|
||||
PriceNumber: parseFloat(expense.Price.replace(/[€$,]/g, '')) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb.getExpenses] Error fetching expenses:', error);
|
||||
console.error('[nocodb.getExpenses] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getExpenseById = async (id: string) => {
|
||||
console.log('[nocodb.getExpenseById] Fetching expense ID:', id);
|
||||
|
||||
try {
|
||||
const result = await $fetch<Expense>(`${createTableUrl(Table.Expense)}/${id}`, {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[nocodb.getExpenseById] Successfully fetched expense:', result.Id);
|
||||
|
||||
// Add computed price number
|
||||
const expenseWithPrice = {
|
||||
...result,
|
||||
PriceNumber: parseFloat(result.Price.replace(/[€$,]/g, '')) || 0
|
||||
};
|
||||
|
||||
return expenseWithPrice;
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb.getExpenseById] Error fetching expense:', error);
|
||||
console.error('[nocodb.getExpenseById] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get current month expenses (default view)
|
||||
export const getCurrentMonthExpenses = async () => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||
|
||||
console.log('[nocodb.getCurrentMonthExpenses] Fetching current month expenses:', startOfMonth, 'to', endOfMonth);
|
||||
|
||||
return getExpenses({
|
||||
startDate: startOfMonth,
|
||||
endDate: endOfMonth
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to group expenses by payer
|
||||
export const groupExpensesByPayer = (expenses: Expense[]) => {
|
||||
const groups = expenses.reduce((acc, expense) => {
|
||||
const payer = expense.Payer || 'Unknown';
|
||||
if (!acc[payer]) {
|
||||
acc[payer] = {
|
||||
name: payer,
|
||||
expenses: [],
|
||||
count: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
acc[payer].expenses.push(expense);
|
||||
acc[payer].count++;
|
||||
acc[payer].total += parseFloat(expense.Price.replace(/[€$,]/g, '')) || 0;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { name: string; expenses: Expense[]; count: number; total: number }>);
|
||||
|
||||
return Object.values(groups);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user