server.js
1 require('dotenv').config(); 2 const express = require('express'); 3 const cors = require('cors'); 4 const OpenAI = require('openai'); 5 const { Client } = require('@notionhq/client'); 6 7 const app = express(); 8 const PORT = process.env.PORT || 5000; 9 10 // Middleware 11 app.use(cors()); 12 app.use(express.json()); 13 14 // Initialize Notion client 15 const notion = new Client({ 16 auth: process.env.NOTION_API_KEY, 17 }); 18 19 const DATABASE_ID = process.env.NOTION_DATABASE_ID; 20 21 // Initialize OpenAI client 22 const openai = new OpenAI({ 23 apiKey: process.env.OPENAI_API_KEY 24 }); 25 26 // Cache for descriptions to avoid repeated LLM calls (persists across requests) 27 const descriptionCache = new Map(); 28 29 // Cache for API responses based on date range: { 'start-end': { ledger: [], summary: {}, timestamp: Date } } 30 const apiCache = new Map(); 31 const CACHE_DURATION = 1000 * 60 * 60; // 1 hour cache duration 32 33 // Category classification keywords 34 const CATEGORY_KEYWORDS = { 35 'Income1': ['income1'], 36 'Income2': ['income2'], 37 'Income3': ['income3'], 38 'Income4': ['income4'], 39 'Income5': ['income5'], 40 'Food & Dining': ['grocery', 'geocery', 'starbucks', 'coffee', 'stsrbucks', 'dinner', 'lunch', 'cafe', 'wee', 'trade joe', 'h mart', 'lamian', 'ice cream', 'snacks', 'dish', 'gum', 'mocha', 'purple drinks', 'ice coffee'], 41 'Transportation': ['uber', 'bus', 'subway', 'ticket', 'air', 'ny-boston', 'travel-taxi', 'travel bus'], 42 'Shopping': ['shein', 'scarf', 'socks', 'case', 'clothes', 'cloth', 'decoration', 'tv', 'mount', 'stand', 'spray', 'mop', 'glue', 'file', 'headphone', 'iphone', 'laptop', 'laplop', 'mask', 'lighting', 'plant', 'battery', 'map', 'item'], 43 'Entertainment': ['tennis', 'card', 'big head', 'headshot', 'boost', 'swimming', 'pool'], 44 'Health & Wellness': ['yoga', 'nail', 'tail', 'white', 'body'], 45 'Utilities & Bills': ['spectrum', 'equipment', 'membership', 'cursor', 'ieee', 'mint', 'mobile', 'apple care', 'ai tools'], 46 'Travel': ['travel', 'visa', 'hotel', 'airbnb', 'chicago', 'boston', 'ohio', 'back home', 'chicago to boston'], 47 'Services': ['logistic', 'application', 'opt', 'editing', 'set up', 'mounting'], 48 'Other': ['daily expense', 'expense'] 49 }; 50 51 // Classify description into category (keyword-based, fast) 52 function classifyDescription(description) { 53 if (!description) return 'Uncategorized'; 54 55 const desc = description.toLowerCase(); 56 57 // First check for Income categories (more specific) 58 if (desc.includes('income1')) return 'Income1'; 59 if (desc.includes('income2')) return 'Income2'; 60 if (desc.includes('income3')) return 'Income3'; 61 if (desc.includes('income4')) return 'Income4'; 62 if (desc.includes('income5')) return 'Income5'; 63 64 // Then check other categories 65 for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) { 66 // Skip Income categories as already checked above 67 if (category.startsWith('Income')) continue; 68 69 for (const keyword of keywords) { 70 if (desc.includes(keyword)) { 71 return category; 72 } 73 } 74 } 75 76 return 'Uncategorized'; 77 } 78 79 // Smart LLM-based classification (for unique/unknown descriptions) 80 async function classifyWithLLM(description) { 81 try { 82 const response = await openai.chat.completions.create({ 83 model: 'gpt-4o-mini', 84 messages: [ 85 { 86 role: 'system', 87 content: `You are a financial transaction classifier. Categorize the description into one of: 88 Income1, Income2, Income3, Income4, Income5, Food & Dining, Transportation, Shopping, Entertainment, 89 Health & Wellness, Utilities & Bills, Travel, Services, or Other. 90 Respond with ONLY the category name, nothing else.` 91 }, 92 { 93 role: 'user', 94 content: `Categorize: "${description}"` 95 } 96 ], 97 temperature: 0.3, 98 max_tokens: 20 99 }); 100 101 const category = response.choices[0].message.content.trim(); 102 return category; 103 } catch (error) { 104 console.error('LLM classification error:', error); 105 return 'Uncategorized'; 106 } 107 } 108 109 // Helper function to fetch ALL pages from Notion (handles pagination) 110 async function fetchAllPages(query) { 111 const allPages = []; 112 let cursor = undefined; 113 114 do { 115 const queryWithCursor = { ...query }; 116 if (cursor) { 117 queryWithCursor.start_cursor = cursor; 118 } 119 120 const response = await notion.databases.query(queryWithCursor); 121 allPages.push(...response.results); 122 cursor = response.next_cursor; 123 } while (cursor); 124 125 return allPages; 126 } 127 128 // API Routes 129 130 // Helper function to get cache key 131 function getCacheKey(start, end) { 132 return `${start || 'all'}-${end || 'all'}`; 133 } 134 135 // Helper function to check if cache is valid 136 function isCacheValid(cacheEntry) { 137 if (!cacheEntry || !cacheEntry.timestamp) return false; 138 const age = Date.now() - cacheEntry.timestamp; 139 return age < CACHE_DURATION; 140 } 141 142 // Get all ledger entries with optional time filtering 143 app.get('/api/ledger', async (req, res) => { 144 try { 145 const { start, end, forceRefresh } = req.query; 146 const cacheKey = getCacheKey(start, end); 147 148 // Check cache first (unless forceRefresh is true) 149 if (forceRefresh !== 'true') { 150 const cached = apiCache.get(cacheKey); 151 if (cached && isCacheValid(cached)) { 152 console.log(`✅ Cache hit for ${cacheKey}`); 153 return res.json(cached.ledger); 154 } 155 } 156 157 console.log(`🔄 Fetching from Notion for ${cacheKey}`); 158 159 // Build query with optional date filter 160 const query = { 161 database_id: DATABASE_ID, 162 sorts: [ 163 { 164 property: 'Date', 165 direction: 'descending', 166 }, 167 ], 168 }; 169 170 // Add date filter if provided 171 if (start || end) { 172 query.filter = { 173 and: [], 174 }; 175 176 if (start) { 177 query.filter.and.push({ 178 property: 'Date', 179 date: { 180 on_or_after: start, 181 }, 182 }); 183 } 184 185 if (end) { 186 query.filter.and.push({ 187 property: 'Date', 188 date: { 189 on_or_before: end, 190 }, 191 }); 192 } 193 } 194 195 // Fetch ALL pages with pagination 196 const allPages = await fetchAllPages(query); 197 198 const entries = await Promise.all(allPages.map(async (page) => { 199 const properties = page.properties; 200 const description = properties.Description?.title?.[0]?.plain_text || ''; 201 202 // Always use LLM to classify (bypass Notion's Category field completely) 203 let category; 204 if (descriptionCache.has(description)) { 205 // Use cached result (fast) 206 category = descriptionCache.get(description); 207 } else { 208 // Call LLM for new/unknown descriptions only 209 category = await classifyWithLLM(description); 210 descriptionCache.set(description, category); 211 } 212 213 const payment = properties.Payment?.number || 0; 214 const deposit = properties.Deposit?.number || 0; 215 216 // Determine Type: 217 // Income = Deposit has value AND Payment is empty/0 218 // Expense = Payment has value (even if Deposit also has value) 219 const type = (deposit > 0 && payment === 0) ? 'Income' : 'Expense'; 220 const amount = (deposit > 0 && payment === 0) ? deposit : payment; 221 222 return { 223 id: page.id, 224 date: properties.Date?.date?.start || '', 225 description: description, 226 payment: payment, 227 deposit: deposit, 228 amount: amount, 229 type: type, 230 account: properties.Account?.select?.name || '', 231 category: category, 232 }; 233 })); 234 235 // Cache the result 236 apiCache.set(cacheKey, { 237 ledger: entries, 238 timestamp: Date.now() 239 }); 240 241 res.json(entries); 242 } catch (error) { 243 console.error('Error fetching ledger:', error); 244 res.status(500).json({ error: 'Failed to fetch ledger entries' }); 245 } 246 }); 247 248 // Get categories summary (supports time filtering via query params) 249 app.get('/api/summary', async (req, res) => { 250 try { 251 const { start, end, forceRefresh } = req.query; 252 const cacheKey = `summary-${getCacheKey(start, end)}`; 253 254 // Check cache first (unless forceRefresh is true) 255 if (forceRefresh !== 'true') { 256 const cached = apiCache.get(cacheKey); 257 if (cached && isCacheValid(cached)) { 258 console.log(`✅ Cache hit for ${cacheKey}`); 259 return res.json(cached.summary); 260 } 261 } 262 263 console.log(`🔄 Fetching summary from Notion for ${cacheKey}`); 264 265 // Build query with optional date filter 266 const query = { 267 database_id: DATABASE_ID, 268 }; 269 270 if (start || end) { 271 query.filter = { and: [] }; 272 273 if (start) { 274 query.filter.and.push({ 275 property: 'Date', 276 date: { on_or_after: start }, 277 }); 278 } 279 280 if (end) { 281 query.filter.and.push({ 282 property: 'Date', 283 date: { on_or_before: end }, 284 }); 285 } 286 } 287 288 // Fetch ALL pages with pagination 289 const allPages = await fetchAllPages(query); 290 291 const summary = {}; 292 293 for (const page of allPages) { 294 const properties = page.properties; 295 const description = properties.Description?.title?.[0]?.plain_text || ''; 296 297 // Always use LLM to classify (bypass Notion's Category field completely) 298 let category; 299 if (descriptionCache.has(description)) { 300 category = descriptionCache.get(description); 301 } else { 302 category = await classifyWithLLM(description); 303 descriptionCache.set(description, category); 304 } 305 306 const payment = properties.Payment?.number || 0; 307 const deposit = properties.Deposit?.number || 0; 308 309 // Determine Type: 310 // Income = Deposit has value AND Payment is empty/0 311 // Expense = Payment has value (even if Deposit also has value) 312 const isIncome = (deposit > 0 && payment === 0); 313 const amount = isIncome ? deposit : payment; 314 315 if (!summary[category]) { 316 summary[category] = { 317 total: 0, 318 count: 0, 319 income: 0, 320 expense: 0, 321 }; 322 } 323 324 summary[category].total += amount; 325 summary[category].count += 1; 326 327 if (isIncome) { 328 summary[category].income += deposit; 329 } else if (payment > 0) { 330 summary[category].expense += payment; 331 } 332 } 333 334 // Cache the result 335 apiCache.set(cacheKey, { 336 summary: summary, 337 timestamp: Date.now() 338 }); 339 340 res.json(summary); 341 } catch (error) { 342 console.error('Error fetching summary:', error); 343 res.status(500).json({ error: 'Failed to fetch summary' }); 344 } 345 }); 346 347 // Add new entry 348 app.post('/api/ledger', async (req, res) => { 349 try { 350 // Clear cache when new entry is added 351 apiCache.clear(); 352 console.log('🔄 Cache cleared after adding new entry'); 353 354 const { date, description, payment, account, category } = req.body; 355 356 // Auto-classify if category not provided 357 const finalCategory = category || classifyDescription(description); 358 359 const response = await notion.pages.create({ 360 parent: { 361 database_id: DATABASE_ID, 362 }, 363 properties: { 364 Date: { 365 date: { 366 start: date, 367 }, 368 }, 369 Description: { 370 title: [ 371 { 372 text: { 373 content: description, 374 }, 375 }, 376 ], 377 }, 378 Payment: { 379 number: payment, 380 }, 381 Account: { 382 select: { 383 name: account, 384 }, 385 }, 386 Category: { 387 select: { 388 name: finalCategory, 389 }, 390 }, 391 }, 392 }); 393 394 res.json({ success: true, id: response.id, category: finalCategory }); 395 } catch (error) { 396 console.error('Error creating entry:', error); 397 res.status(500).json({ error: 'Failed to create entry' }); 398 } 399 }); 400 401 // Get time-based analysis (daily, weekly, monthly) 402 app.get('/api/analysis/time', async (req, res) => { 403 try { 404 const allPages = await fetchAllPages({ database_id: DATABASE_ID }); 405 406 const daily = {}; 407 const weekly = {}; 408 const monthly = {}; 409 410 allPages.forEach(page => { 411 const properties = page.properties; 412 const dateStr = properties.Date?.date?.start; 413 if (!dateStr) return; 414 415 const date = new Date(dateStr); 416 const payment = properties.Payment?.number || 0; 417 418 // Daily 419 const dayKey = date.toISOString().split('T')[0]; 420 daily[dayKey] = (daily[dayKey] || 0) + payment; 421 422 // Weekly 423 const weekStart = new Date(date); 424 weekStart.setDate(date.getDate() - date.getDay()); 425 const weekKey = weekStart.toISOString().split('T')[0]; 426 weekly[weekKey] = (weekly[weekKey] || 0) + payment; 427 428 // Monthly 429 const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; 430 monthly[monthKey] = (monthly[monthKey] || 0) + payment; 431 }); 432 433 res.json({ daily, weekly, monthly }); 434 } catch (error) { 435 console.error('Error fetching time analysis:', error); 436 res.status(500).json({ error: 'Failed to fetch time analysis' }); 437 } 438 }); 439 440 // Get account-based analysis 441 app.get('/api/analysis/account', async (req, res) => { 442 try { 443 const allPages = await fetchAllPages({ database_id: DATABASE_ID }); 444 445 const accounts = {}; 446 447 allPages.forEach(page => { 448 const properties = page.properties; 449 const account = properties.Account?.select?.name || 'Other'; 450 const payment = properties.Payment?.number || 0; 451 452 if (!accounts[account]) { 453 accounts[account] = { 454 total: 0, 455 count: 0, 456 }; 457 } 458 459 accounts[account].total += payment; 460 accounts[account].count += 1; 461 }); 462 463 res.json(accounts); 464 } catch (error) { 465 console.error('Error fetching account analysis:', error); 466 res.status(500).json({ error: 'Failed to fetch account analysis' }); 467 } 468 }); 469 470 // Get merchant/description analysis 471 app.get('/api/analysis/merchant', async (req, res) => { 472 try { 473 const allPages = await fetchAllPages({ database_id: DATABASE_ID }); 474 475 const merchants = {}; 476 477 allPages.forEach(page => { 478 const properties = page.properties; 479 const description = properties.Description?.title?.[0]?.plain_text || ''; 480 const payment = properties.Payment?.number || 0; 481 482 if (!merchants[description]) { 483 merchants[description] = { 484 total: 0, 485 count: 0, 486 }; 487 } 488 489 merchants[description].total += payment; 490 merchants[description].count += 1; 491 }); 492 493 // Sort by total descending 494 const sorted = Object.entries(merchants) 495 .map(([name, data]) => ({ name, ...data })) 496 .sort((a, b) => b.total - a.total) 497 .slice(0, 20); // Top 20 498 499 res.json(sorted); 500 } catch (error) { 501 console.error('Error fetching merchant analysis:', error); 502 res.status(500).json({ error: 'Failed to fetch merchant analysis' }); 503 } 504 }); 505 506 // Get trends (daily spending trend) 507 app.get('/api/analysis/trends', async (req, res) => { 508 try { 509 const allPages = await fetchAllPages({ 510 database_id: DATABASE_ID, 511 sorts: [{ property: 'Date', direction: 'ascending' }], 512 }); 513 514 const dailyTrend = []; 515 let runningTotal = 0; 516 517 allPages.forEach(page => { 518 const properties = page.properties; 519 const dateStr = properties.Date?.date?.start; 520 const payment = properties.Payment?.number || 0; 521 522 if (dateStr) { 523 runningTotal += payment; 524 dailyTrend.push({ 525 date: dateStr, 526 payment, 527 cumulative: runningTotal, 528 }); 529 } 530 }); 531 532 res.json(dailyTrend); 533 } catch (error) { 534 console.error('Error fetching trends:', error); 535 res.status(500).json({ error: 'Failed to fetch trends' }); 536 } 537 }); 538 539 // Get balance analysis (income vs expense) 540 app.get('/api/analysis/balance', async (req, res) => { 541 try { 542 const allPages = await fetchAllPages({ database_id: DATABASE_ID }); 543 544 let totalIncome = 0; 545 let totalExpense = 0; 546 547 allPages.forEach(page => { 548 const properties = page.properties; 549 const payment = properties.Payment?.number || 0; 550 const deposit = properties.Deposit?.number || 0; 551 552 // Income = Deposit has value AND Payment is empty/0 553 // Expense = Payment has value (even if Deposit also has value) 554 const isIncome = (deposit > 0 && payment === 0); 555 556 if (isIncome) { 557 totalIncome += deposit; 558 } else if (payment > 0) { 559 totalExpense += payment; 560 } 561 }); 562 563 res.json({ 564 income: totalIncome, 565 expense: totalExpense, 566 balance: totalIncome - totalExpense, 567 }); 568 } catch (error) { 569 console.error('Error fetching balance analysis:', error); 570 res.status(500).json({ error: 'Failed to fetch balance analysis' }); 571 } 572 }); 573 574 // Get all available categories 575 app.get('/api/categories', (req, res) => { 576 const categories = Object.keys(CATEGORY_KEYWORDS); 577 res.json(categories); 578 }); 579 580 // Classify a description (uses keyword matching, then LLM if needed) 581 app.post('/api/classify', async (req, res) => { 582 try { 583 const { description } = req.body; 584 if (!description) { 585 return res.status(400).json({ error: 'Description is required' }); 586 } 587 588 // Try keyword matching first 589 let category = classifyDescription(description); 590 591 // If uncategorized, try LLM 592 if (category === 'Uncategorized') { 593 category = await classifyWithLLM(description); 594 } 595 596 res.json({ description, category }); 597 } catch (error) { 598 console.error('Error classifying:', error); 599 res.status(500).json({ error: 'Failed to classify description' }); 600 } 601 }); 602 603 // Analyze all descriptions and return discovered categories (LLM-powered) 604 app.get('/api/analyze-categories', async (req, res) => { 605 try { 606 const allPages = await fetchAllPages({ database_id: DATABASE_ID }); 607 608 const uniqueDescriptions = new Set(); 609 allPages.forEach(page => { 610 const description = page.properties.Description?.title?.[0]?.plain_text || ''; 611 if (description) uniqueDescriptions.add(description); 612 }); 613 614 const descriptions = Array.from(uniqueDescriptions); 615 616 // Use LLM to analyze and categorize 617 const categories = {}; 618 for (const desc of descriptions) { 619 const category = await classifyWithLLM(desc); 620 if (!categories[category]) { 621 categories[category] = []; 622 } 623 categories[category].push(desc); 624 } 625 626 res.json({ 627 totalDescriptions: descriptions.length, 628 uniqueCategories: Object.keys(categories).length, 629 categories: categories 630 }); 631 } catch (error) { 632 console.error('Error analyzing categories:', error); 633 res.status(500).json({ error: 'Failed to analyze categories' }); 634 } 635 }); 636 637 // Batch update categories for all uncategorized entries 638 app.post('/api/update-categories', async (req, res) => { 639 try { 640 const response = await notion.databases.query({ 641 database_id: DATABASE_ID, 642 }); 643 644 const updates = []; 645 let successCount = 0; 646 let errorCount = 0; 647 648 for (const page of response.results) { 649 const properties = page.properties; 650 const currentCategory = properties.Category?.select?.name; 651 const description = properties.Description?.title?.[0]?.plain_text || ''; 652 653 // Only update if uncategorized 654 if (currentCategory === 'Uncategorized' || !currentCategory) { 655 const newCategory = classifyDescription(description); 656 657 if (newCategory && newCategory !== 'Uncategorized') { 658 try { 659 await notion.pages.update({ 660 page_id: page.id, 661 properties: { 662 Category: { 663 select: { 664 name: newCategory, 665 }, 666 }, 667 }, 668 }); 669 successCount++; 670 } catch (error) { 671 console.error(`Error updating page ${page.id}:`, error); 672 errorCount++; 673 } 674 } 675 } 676 } 677 678 res.json({ 679 success: true, 680 updated: successCount, 681 errors: errorCount, 682 message: `Successfully updated ${successCount} entries. ${errorCount} errors.` 683 }); 684 } catch (error) { 685 console.error('Error updating categories:', error); 686 res.status(500).json({ error: 'Failed to update categories' }); 687 } 688 }); 689 690 // AI Insights - Generate comprehensive spending insights 691 app.get('/api/ai-insights', async (req, res) => { 692 try { 693 const { start, end } = req.query; 694 695 // Build query with optional date filter 696 const query = { 697 database_id: DATABASE_ID, 698 sorts: [{ property: 'Date', direction: 'descending' }], 699 }; 700 701 if (start || end) { 702 query.filter = { and: [] }; 703 if (start) { 704 query.filter.and.push({ property: 'Date', date: { on_or_after: start } }); 705 } 706 if (end) { 707 query.filter.and.push({ property: 'Date', date: { on_or_before: end } }); 708 } 709 } 710 711 const allPages = await fetchAllPages(query); 712 713 // Process ledger entries 714 const entries = []; 715 for (const page of allPages) { 716 const properties = page.properties; 717 const description = properties.Description?.title?.[0]?.plain_text || ''; 718 719 let category; 720 if (descriptionCache.has(description)) { 721 category = descriptionCache.get(description); 722 } else { 723 category = await classifyWithLLM(description); 724 descriptionCache.set(description, category); 725 } 726 727 const payment = properties.Payment?.number || 0; 728 const deposit = properties.Deposit?.number || 0; 729 const isIncome = (deposit > 0 && payment === 0); 730 const amount = isIncome ? deposit : payment; 731 732 entries.push({ 733 date: properties.Date?.date?.start || '', 734 description, 735 category, 736 amount, 737 type: isIncome ? 'Income' : 'Expense', 738 account: properties.Account?.select?.name || '', 739 }); 740 } 741 742 // Calculate statistics 743 const totalIncome = entries.filter(e => e.type === 'Income').reduce((sum, e) => sum + e.amount, 0); 744 const totalExpense = entries.filter(e => e.type === 'Expense').reduce((sum, e) => sum + e.amount, 0); 745 const avgDailyExpense = totalExpense / Math.max(1, new Set(entries.map(e => e.date?.split('T')[0])).size); 746 747 // Category statistics 748 const categoryStats = {}; 749 entries.filter(e => e.type === 'Expense').forEach(e => { 750 if (!categoryStats[e.category]) { 751 categoryStats[e.category] = { total: 0, count: 0, avg: 0 }; 752 } 753 categoryStats[e.category].total += e.amount; 754 categoryStats[e.category].count += 1; 755 }); 756 Object.keys(categoryStats).forEach(cat => { 757 categoryStats[cat].avg = categoryStats[cat].total / categoryStats[cat].count; 758 }); 759 760 // Top spending categories 761 const topCategories = Object.entries(categoryStats) 762 .sort((a, b) => b[1].total - a[1].total) 763 .slice(0, 5) 764 .map(([cat, stats]) => ({ category: cat, ...stats })); 765 766 // Detect anomalies (spending > 3x average in that category) 767 const anomalies = entries.filter(e => { 768 if (e.type !== 'Expense' || !categoryStats[e.category]) return false; 769 return e.amount > categoryStats[e.category].avg * 3; 770 }); 771 772 // Prepare data for LLM 773 const context = { 774 period: start && end ? `${start} to ${end}` : 'all time', 775 totalIncome, 776 totalExpense, 777 balance: totalIncome - totalExpense, 778 avgDailyExpense, 779 categories: topCategories, 780 anomalies: anomalies.length, 781 totalEntries: entries.length, 782 }; 783 784 // Generate AI insights using OpenAI 785 const systemPrompt = `You are a financial advisor analyzing spending data. Provide: 786 1. A concise 2-3 sentence spending summary 787 2. List 3-5 actionable recommendations to improve financial health 788 3. Highlight 1-2 unusual spending patterns if any 789 790 Be friendly, specific, and data-driven.`; 791 792 const userPrompt = `Analyze this spending data from ${context.period}: 793 - Total Income: $${context.totalIncome.toFixed(2)} 794 - Total Expenses: $${context.totalExpense.toFixed(2)} 795 - Balance: $${context.balance.toFixed(2)} 796 - Average Daily Spending: $${context.avgDailyExpense.toFixed(2)} 797 - Top Spending Categories: ${topCategories.map(c => `${c.category} ($${c.total.toFixed(2)})`).join(', ')} 798 - Anomalies detected: ${anomalies.length} transactions 799 - Total entries: ${context.totalEntries} 800 801 ${anomalies.length > 0 ? `Notable unusual expenses:\n${anomalies.slice(0, 5).map(a => `- ${a.description}: $${a.amount.toFixed(2)} in ${a.category}`).join('\n')}` : ''} 802 803 Provide your analysis and recommendations.`; 804 805 const llmResponse = await openai.chat.completions.create({ 806 model: 'gpt-4o-mini', 807 messages: [ 808 { role: 'system', content: systemPrompt }, 809 { role: 'user', content: userPrompt } 810 ], 811 temperature: 0.7, 812 max_tokens: 500 813 }); 814 815 const aiAnalysis = llmResponse.choices[0].message.content.trim(); 816 817 res.json({ 818 summary: { 819 totalIncome, 820 totalExpense, 821 balance: totalIncome - totalExpense, 822 avgDailyExpense, 823 totalEntries: entries.length, 824 period: context.period, 825 }, 826 topCategories, 827 anomalies: anomalies.slice(0, 10), 828 aiInsights: aiAnalysis, 829 categoryStats, 830 }); 831 } catch (error) { 832 console.error('Error generating AI insights:', error); 833 res.status(500).json({ error: 'Failed to generate AI insights' }); 834 } 835 }); 836 837 // Health check 838 app.get('/api/health', (req, res) => { 839 res.json({ status: 'ok' }); 840 }); 841 842 // Export for Vercel serverless 843 module.exports = app; 844 845 // Only listen in development 846 if (process.env.NODE_ENV !== 'production') { 847 app.listen(PORT, () => { 848 console.log(`Server running on port ${PORT}`); 849 }); 850 } 851