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:
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