291 lines
9.9 KiB
TypeScript
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}`);
|