ynab-amazon-helper/server.ts
2025-11-04 12:27:02 -05:00

291 lines
9.9 KiB
TypeScript

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<string, TransactionMatch[]>();
// 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<APIResponse<null>>({
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<APIResponse<null>>({
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<APIResponse<{ sessionId: string; stats: typeof stats }>>({
success: true,
data: { sessionId, stats },
});
} catch (error) {
console.error('Error fetching matches:', error);
return Response.json<APIResponse<null>>({
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<APIResponse<null>>({
success: false,
error: 'Session not found',
}, { status: 404 });
}
return Response.json<APIResponse<TransactionMatch[]>>({
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<APIResponse<null>>({
success: false,
error: 'Session not found',
}, { status: 404 });
}
if (matchIndex < 0 || matchIndex >= matches.length) {
return Response.json<APIResponse<null>>({
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<APIResponse<TransactionMatch>>({
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<APIResponse<null>>({
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<APIResponse<typeof results>>({
success: true,
data: results,
});
},
},
// API: Get YNAB categories
'/api/categories': {
GET: async () => {
try {
const categories = await ynabClient.getCategories();
return Response.json<APIResponse<typeof categories>>({
success: true,
data: categories,
});
} catch (error) {
return Response.json<APIResponse<null>>({
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}`);