import { AmazonScraper } from './amazon-scraper'; import { YNABClient } from './ynab-client'; import { TransactionMatcher } from './matcher'; import { CategorySuggester } from './category-suggester'; import type { TransactionMatch, APIResponse } from './types'; import indexHtml from './index.html'; // In-memory storage for matches (in production, use a database) const matchesStore = new Map(); // Environment variables const YNAB_API_TOKEN = process.env.YNAB_API_TOKEN!; const YNAB_BUDGET_ID = process.env.YNAB_BUDGET_ID!; const OPENAI_API_KEY = process.env.OPENAI_API_KEY!; const PORT = parseInt(process.env.PORT || '3000'); // Initialize clients const ynabClient = new YNABClient(YNAB_API_TOKEN, YNAB_BUDGET_ID); const amazonScraper = new AmazonScraper(); // No credentials needed - manual login const categorySuggester = new CategorySuggester(OPENAI_API_KEY); const matcher = new TransactionMatcher(); // Generate a session ID for storing matches function generateSessionId(): string { return `session_${Date.now()}_${Math.random().toString(36).substring(7)}`; } const server = Bun.serve({ port: PORT, routes: { // Serve the main page '/': indexHtml, // API: Fetch and match transactions '/api/fetch-matches': { POST: async (req) => { try { const body = await req.json(); const { startDate, endDate, useUnapproved } = body; let ynabTransactions; let amazonOrders; if (useUnapproved) { // Fetch unapproved transactions console.log('Fetching unapproved YNAB transactions...'); ynabTransactions = await ynabClient.getUnapprovedTransactions(); console.log(`Found ${ynabTransactions.length} unapproved YNAB transactions`); // Determine date range from unapproved transactions if (ynabTransactions.length === 0) { return Response.json>({ success: false, error: 'No unapproved transactions found', }, { status: 404 }); } const dates = ynabTransactions.map(t => new Date(t.date)); const minDate = new Date(Math.min(...dates.map(d => d.getTime()))); const maxDate = new Date(Math.max(...dates.map(d => d.getTime()))); // Add buffer for Amazon order dates (±7 days) const bufferDays = 7; minDate.setDate(minDate.getDate() - bufferDays); maxDate.setDate(maxDate.getDate() + bufferDays); console.log(`Scraping Amazon orders from ${minDate.toISOString().split('T')[0]} to ${maxDate.toISOString().split('T')[0]}...`); // Scrape Amazon orders for the date range amazonOrders = await amazonScraper.scrapeOrders(minDate, maxDate); console.log(`Found ${amazonOrders.length} Amazon orders`); } else { // Use date range if provided if (!startDate || !endDate) { return Response.json>({ success: false, error: 'startDate and endDate are required when not using unapproved transactions', }, { status: 400 }); } console.log(`Fetching transactions from ${startDate} to ${endDate}...`); // Fetch YNAB transactions ynabTransactions = await ynabClient.getTransactions(startDate, endDate); console.log(`Found ${ynabTransactions.length} YNAB transactions`); // Scrape Amazon orders amazonOrders = await amazonScraper.scrapeOrders( new Date(startDate), new Date(endDate) ); console.log(`Found ${amazonOrders.length} Amazon orders`); } // Match transactions let matches = matcher.matchTransactions(ynabTransactions, amazonOrders); console.log(`Created ${matches.length} matches`); // Fetch product details only for matched Amazon orders const matchedOrders = matches .filter(m => m.amazonOrder !== null) .map(m => m.amazonOrder!); if (matchedOrders.length > 0) { console.log(`Fetching product details for ${matchedOrders.length} matched Amazon orders...`); const ordersWithDetails = await amazonScraper.fetchProductDetails(matchedOrders); // Update matches with enriched order data const orderMap = new Map(ordersWithDetails.map(order => [order.orderId, order])); matches = matches.map(match => { if (match.amazonOrder && orderMap.has(match.amazonOrder.orderId)) { return { ...match, amazonOrder: orderMap.get(match.amazonOrder.orderId)!, }; } return match; }); console.log('Product details fetched and merged into matches'); } // Get YNAB categories const categories = await ynabClient.getCategories(); console.log(`Found ${categories.length} YNAB categories`); // Suggest categories using OpenAI console.log('Suggesting categories with OpenAI...'); matches = await categorySuggester.suggestCategories(matches, categories); // Store matches with a session ID const sessionId = generateSessionId(); matchesStore.set(sessionId, matches); // Get statistics const stats = matcher.getMatchStatistics(matches); return Response.json>({ success: true, data: { sessionId, stats }, }); } catch (error) { console.error('Error fetching matches:', error); return Response.json>({ success: false, error: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 }); } }, }, // API: Get matches for a session '/api/matches/:sessionId': { GET: async (req) => { const sessionId = req.params.sessionId; const matches = matchesStore.get(sessionId); if (!matches) { return Response.json>({ success: false, error: 'Session not found', }, { status: 404 }); } return Response.json>({ success: true, data: matches, }); }, }, // API: Update match status '/api/matches/:sessionId/:matchIndex': { PATCH: async (req) => { const sessionId = req.params.sessionId; const matchIndex = parseInt(req.params.matchIndex); const matches = matchesStore.get(sessionId); if (!matches) { return Response.json>({ success: false, error: 'Session not found', }, { status: 404 }); } if (matchIndex < 0 || matchIndex >= matches.length) { return Response.json>({ success: false, error: 'Invalid match index', }, { status: 400 }); } const body = await req.json(); const { status, suggestedCategory } = body; if (status) { matches[matchIndex].status = status; } if (suggestedCategory !== undefined) { matches[matchIndex].suggestedCategory = suggestedCategory; } matchesStore.set(sessionId, matches); return Response.json>({ success: true, data: matches[matchIndex], }); }, }, // API: Approve and update YNAB transactions '/api/approve-matches/:sessionId': { POST: async (req) => { const sessionId = req.params.sessionId; const matches = matchesStore.get(sessionId); if (!matches) { return Response.json>({ success: false, error: 'Session not found', }, { status: 404 }); } const approvedMatches = matches.filter((m) => m.status === 'approved'); const results = []; for (const match of approvedMatches) { try { if (!match.amazonOrder) continue; // Build the new memo with Amazon order link const currentMemo = match.ynabTransaction.memo || ''; const amazonLink = match.amazonOrder.orderUrl; const newMemo = currentMemo ? `${currentMemo} | Amazon: ${amazonLink}` : amazonLink; // Update the transaction await ynabClient.updateTransaction( match.ynabTransaction.id, newMemo, match.suggestedCategory?.id ); results.push({ transactionId: match.ynabTransaction.id, success: true, }); } catch (error) { results.push({ transactionId: match.ynabTransaction.id, success: false, error: error instanceof Error ? error.message : 'Unknown error', }); } } return Response.json>({ success: true, data: results, }); }, }, // API: Get YNAB categories '/api/categories': { GET: async () => { try { const categories = await ynabClient.getCategories(); return Response.json>({ success: true, data: categories, }); } catch (error) { return Response.json>({ success: false, error: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 }); } }, }, }, development: { hmr: true, console: true, }, }); console.log(`🚀 Server running at http://localhost:${server.port}`);