commit f93bc87042afd13455b89d97a629a7e3279ee942 Author: sloane Date: Tue Nov 4 12:26:58 2025 -0500 vibe coding diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ed32d0d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# YNAB API Configuration +YNAB_API_TOKEN=your_ynab_personal_access_token_here +YNAB_BUDGET_ID=your_budget_id_here + +# OpenAI API Configuration +OPENAI_API_KEY=your_openai_api_key_here + +# Server Configuration +PORT=3000 +NODE_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b68d88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo +.puppeteer_cache + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Debug files +debug-*.png +debug-*.html diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ee6890 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc45c3b --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# YNAB Amazon Helper + +Automatically link your YNAB transactions to Amazon orders and categorize them using AI. + +## Features + +- **Unapproved Transaction Workflow**: Automatically fetch and match only unapproved YNAB transactions (no date range needed!) +- **Automated Matching**: Matches YNAB transactions with Amazon orders based on amount and date proximity +- **Web Scraping**: Scrapes your Amazon order history using Puppeteer (no Amazon API keys required) +- **AI-Powered Categorization**: Uses OpenAI to suggest appropriate YNAB categories based on product descriptions +- **Visual Review Interface**: Web UI to review, approve, and modify matches before applying +- **Batch Updates**: Apply approved matches to YNAB with one click, automatically marking them as approved + +## Prerequisites + +- [Bun](https://bun.sh) runtime installed +- YNAB account with API access +- Amazon account +- OpenAI API key + +## Setup + +### 1. Install Dependencies + +```bash +bun install +``` + +### 2. Configure Environment Variables + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` and fill in your credentials: + +```env +# YNAB API Configuration +YNAB_API_TOKEN=your_ynab_personal_access_token +YNAB_BUDGET_ID=your_budget_id + +# OpenAI API Configuration +OPENAI_API_KEY=your_openai_api_key + +# Server Configuration +PORT=3000 +NODE_ENV=development +``` + +**Note:** Amazon credentials are NOT required. You'll log in manually through the browser when scraping. + +### 3. Get Your YNAB API Token + +1. Go to [YNAB Account Settings](https://app.ynab.com/settings) +2. Navigate to "Developer Settings" +3. Click "New Token" and copy the personal access token +4. To find your Budget ID, you can use the YNAB API or check the URL when viewing your budget + +### 4. Get Your OpenAI API Key + +1. Go to [OpenAI API Keys](https://platform.openai.com/api-keys) +2. Create a new secret key +3. Copy the key (you won't be able to see it again) + +## Usage + +### Start the Server + +```bash +bun --hot server.ts +``` + +The server will start at `http://localhost:3000` + +### Using the Application + +1. **Open the Web Interface**: Navigate to `http://localhost:3000` in your browser + +2. **Choose Transaction Mode**: + - **Unapproved Transactions (Recommended)**: Automatically fetches all unapproved transactions from YNAB + - **Date Range**: Manually specify start and end dates for transactions to match + +3. **Fetch Matches**: Click "Fetch & Match Transactions" + - The app will fetch YNAB transactions (unapproved or by date range) + - Scrape Amazon orders from the relevant time period (opens a browser window) + - Match transactions using date/amount proximity + - Suggest categories using AI + +4. **Review Matches**: + - Each match shows the YNAB transaction and corresponding Amazon order + - Confidence scores help identify quality matches + - Review AI-suggested categories and modify if needed + - Approve or reject each match + +5. **Apply Changes**: Click "Apply Approved Matches" to update YNAB + - Amazon order links will be added to transaction memos + - Categories will be updated if changed + - Transactions will be marked as approved + +## How It Works + +### Transaction Matching Algorithm + +The matcher uses a scoring system based on: +- **Amount Matching (50% weight)**: Compares transaction amount with order total +- **Date Proximity (30% weight)**: Considers orders within ±3 days of transaction +- **Payee Name (20% weight)**: Boosts score for Amazon-related payees + +Default tolerances: +- Date: ±3 days +- Amount: ±$0.50 + +### AI Categorization + +OpenAI analyzes product descriptions and suggests categories from your YNAB budget: +- Uses GPT-4o-mini for cost efficiency +- Considers all items in multi-item orders +- Maps products to your existing category structure + +### Amazon Scraping + +The scraper: +- Uses Puppeteer to automate browser interactions +- Navigates to Amazon transactions page (`https://www.amazon.com/cpe/yourpayments/transactions`) +- Automatically logs into your Amazon account if needed +- Extracts transaction details, dates, and amounts +- Caches session data to avoid repeated logins +- Takes debug screenshots for troubleshooting + +**Important Notes**: +- A browser window will open and navigate to the Amazon transactions page +- **You'll need to log in manually** - the app waits 45 seconds for you to complete login +- Once logged in, the session is cached in `.puppeteer_cache/` for future runs +- If you're already logged in (from a previous session), scraping will start automatically +- Debug screenshots are saved to `debug-transactions-page.png` for troubleshooting + +## Project Structure + +``` +├── server.ts # Bun.serve backend API +├── index.html # Main HTML page +├── frontend.tsx # Frontend TypeScript/React +├── types.ts # TypeScript type definitions +├── amazon-scraper.ts # Amazon order scraping logic +├── ynab-client.ts # YNAB API integration +├── matcher.ts # Transaction matching algorithm +├── category-suggester.ts # OpenAI category suggestions +└── .env # Environment configuration (not in git) +``` + +## API Endpoints + +- `GET /` - Serve web interface +- `POST /api/fetch-matches` - Fetch and match transactions +- `GET /api/matches/:sessionId` - Get matches for a session +- `PATCH /api/matches/:sessionId/:matchIndex` - Update match status +- `POST /api/approve-matches/:sessionId` - Apply approved matches to YNAB +- `GET /api/categories` - Get YNAB categories + +## Troubleshooting + +### Amazon Login Issues + +- **2FA Required**: Complete 2FA manually when the browser opens +- **CAPTCHA**: Solve any CAPTCHA challenges that appear +- **Session Expired**: Delete `.puppeteer_cache/` and try again + +### YNAB API Issues + +- **Invalid Token**: Regenerate your personal access token +- **Budget Not Found**: Verify your budget ID is correct +- **Rate Limits**: The app respects YNAB rate limits automatically + +### OpenAI Issues + +- **Rate Limits**: Categories are suggested in batches with delays +- **Cost Concerns**: Uses gpt-4o-mini (~$0.15 per 1M tokens) +- **Poor Suggestions**: Review and manually select correct categories + +## Security Considerations + +- Never commit `.env` file to version control +- Amazon credentials are stored locally only +- YNAB token has read/write access to your budget +- Puppeteer cache may contain session cookies + +## Future Enhancements + +- [ ] Support for multiple budgets +- [ ] Database for persistent match storage +- [ ] Manual order entry (for non-Amazon purchases) +- [ ] Improved scraping with stealth plugins +- [ ] Export/import match history +- [ ] Category learning from past matches + +## License + +MIT + +## Built With + +- [Bun](https://bun.sh) - Fast JavaScript runtime +- [Puppeteer](https://pptr.dev) - Headless browser automation +- [YNAB API](https://api.ynab.com) - Budget data integration +- [OpenAI API](https://platform.openai.com) - AI categorization diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..e400a39 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,106 @@ +# Troubleshooting - Collecting Debug Output + +## Step 1: Make sure you have a .env file set up + +Check that you have a `.env` file with at least: +``` +YNAB_API_TOKEN=your_token +YNAB_BUDGET_ID=your_budget_id +OPENAI_API_KEY=your_openai_key +``` + +## Step 2: Start the server + +In your terminal, run: +```bash +bun --hot server.ts +``` + +You should see: +``` +🚀 Server running at http://localhost:3000 +``` + +## Step 3: Open the web interface + +Open your browser and go to: +``` +http://localhost:3000 +``` + +## Step 4: Start the scraping process + +1. On the web page, make sure "Use Unapproved Transactions Only" is checked (it should be by default) +2. Click the **"Fetch & Match Transactions"** button + +## Step 5: Log in to Amazon manually + +A browser window will open automatically. You have 45 seconds to: +1. Log in to Amazon if prompted +2. Complete any 2FA or CAPTCHA +3. Wait for the transactions page to load + +## Step 6: Watch the terminal output + +Back in your terminal where you ran `bun --hot server.ts`, you'll see output like: + +``` +Navigating to Amazon transactions page... +Please log in manually if needed. The browser will wait for you. +Waiting 45 seconds for you to complete login (if needed)... +Screenshot saved to debug-transactions-page.png +HTML saved to debug-transactions-page.html +Extracting transaction data... +Debug Info: +- Total divs on page: 1234 +- Relevant class names found: [ ... ] +- Data-testid attributes found: [ ... ] +- First 500 chars of page: ... +Extracted 0 transactions +WARNING: No transactions were extracted from the page! +``` + +## Step 7: Collect the debug files + +After the scraping completes, you'll have two new files in your project folder: +- `debug-transactions-page.png` - Screenshot +- `debug-transactions-page.html` - Full HTML + +## Step 8: Analyze the HTML (optional) + +Run this to see what's in the HTML: +```bash +bun inspect-page.ts +``` + +## What to share for help + +Copy and paste: +1. The entire terminal output from "Debug Info:" onwards +2. Look at `debug-transactions-page.png` - does it show your transactions? +3. If you can, share the output from `bun inspect-page.ts` + +## Quick Test + +If you just want to test the scraper without the full app, you can create a simple test file: + +```typescript +// test-scraper.ts +import { AmazonScraper } from './amazon-scraper'; + +const scraper = new AmazonScraper(); +const endDate = new Date(); +const startDate = new Date(); +startDate.setDate(startDate.getDate() - 30); + +const orders = await scraper.scrapeOrders(startDate, endDate); +console.log('Orders found:', orders.length); +console.log('Orders:', JSON.stringify(orders, null, 2)); +``` + +Then run: +```bash +bun test-scraper.ts +``` + +This will open the browser, let you log in, and show you all the debug output! diff --git a/amazon-scraper.ts b/amazon-scraper.ts new file mode 100644 index 0000000..011d42d --- /dev/null +++ b/amazon-scraper.ts @@ -0,0 +1,424 @@ +import puppeteer from 'puppeteer'; +import type { AmazonOrder, AmazonOrderItem } from './types'; + +export class AmazonScraper { + constructor() { + // No credentials needed - user logs in manually + } + + /** + * Helper function to wait/sleep for a specified duration + * @param ms - Milliseconds to wait + */ + private async wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Scrape Amazon orders for a given date range + * @param startDate - Start date for orders + * @param endDate - End date for orders + * @returns Array of Amazon orders + */ + async scrapeOrders(startDate: Date, endDate: Date): Promise { + const browser = await puppeteer.launch({ + headless: false, // Set to true in production, false for debugging + userDataDir: './.puppeteer_cache', // Persist session + args: ['--window-size=1920,1080'], + }); + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // Set user agent to avoid detection + await page.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + + console.log('Navigating to Amazon transactions page...'); + console.log('Please log in manually if needed. The browser will wait for you.'); + + // Navigate directly to transactions page - this will redirect to login if needed + await page.goto('https://www.amazon.com/cpe/yourpayments/transactions', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + + // Wait for user to manually log in if needed + console.log('Waiting 15 seconds for you to complete login (if needed)...'); + console.log('If you\'re already logged in, the page should load automatically.'); + await this.wait(15000); + + // Navigate to transactions page again in case login redirected elsewhere + console.log('Ensuring we\'re on the transactions page...'); + await page.goto('https://www.amazon.com/cpe/yourpayments/transactions', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + + await this.wait(3000); + + // Take a screenshot for debugging + await page.screenshot({ path: 'debug-transactions-page.png', fullPage: true }); + console.log('Screenshot saved to debug-transactions-page.png'); + + // Save HTML for inspection + const html = await page.content(); + await Bun.write('debug-transactions-page.html', html); + console.log('HTML saved to debug-transactions-page.html'); + + // Scrape transactions from the page + const orders: AmazonOrder[] = []; + + console.log('Extracting transaction data...'); + + // First, let's debug what's actually on the page + const debugInfo = await page.evaluate(() => { + // Get all elements that might be transactions + const allDivs = document.querySelectorAll('div'); + const classNames = new Set(); + const dataTestIds = new Set(); + + allDivs.forEach(div => { + if (div.className && typeof div.className === 'string') { + div.className.split(' ').forEach(cls => { + if (cls.toLowerCase().includes('transaction') || + cls.toLowerCase().includes('payment') || + cls.toLowerCase().includes('order')) { + classNames.add(cls); + } + }); + } + const testId = div.getAttribute('data-testid'); + if (testId) { + dataTestIds.add(testId); + } + }); + + return { + totalDivs: allDivs.length, + relevantClasses: Array.from(classNames), + dataTestIds: Array.from(dataTestIds), + bodyText: document.body.innerText.substring(0, 500), + }; + }); + + console.log('Debug Info:'); + console.log('- Total divs on page:', debugInfo.totalDivs); + console.log('- Relevant class names found:', debugInfo.relevantClasses); + console.log('- Data-testid attributes found:', debugInfo.dataTestIds); + console.log('- First 500 chars of page:', debugInfo.bodyText); + + // Extract transaction information from the page + const transactions = await page.evaluate(() => { + const extracted: any[] = []; + + // Find all date containers first + const dateContainers = document.querySelectorAll('.apx-transaction-date-container'); + console.log('Found date containers:', dateContainers.length); + + dateContainers.forEach((dateContainer) => { + const dateText = dateContainer.textContent?.trim() || ''; + + // Find the next sibling that contains transaction line items + let currentElement = dateContainer.nextElementSibling; + + while (currentElement) { + // Stop if we hit the next date container + if (currentElement.classList.contains('apx-transaction-date-container')) { + break; + } + + // Find all transaction line items within this section + const transactionRows = currentElement.querySelectorAll('.apx-transactions-line-item-component-container'); + + transactionRows.forEach((row) => { + try { + // Extract amount - look for negative dollar amounts + const allText = row.textContent || ''; + const amountMatch = allText.match(/-?\$\s*([\d,]+\.\d{2})/); + const total = amountMatch ? Math.abs(parseFloat(amountMatch[1].replace(/,/g, ''))) : 0; + + // Extract order link and ID + const orderLink = row.querySelector('a[href*="orderID"]'); + const href = orderLink?.getAttribute('href') || ''; + const orderIdMatch = href.match(/orderID=([A-Z0-9-]+)/i); + const orderId = orderIdMatch ? orderIdMatch[1] : ''; + + // Extract merchant/description - the last span usually contains it + const spans = row.querySelectorAll('span.a-size-base'); + let description = 'Amazon Purchase'; + if (spans.length > 0) { + // The last span usually has the merchant name + const merchantSpan = spans[spans.length - 1]; + description = merchantSpan?.textContent?.trim() || 'Amazon Purchase'; + } + + console.log(`Transaction: Date="${dateText}", Amount=$${total}, Desc="${description}", Order=${orderId}`); + + if (dateText && total > 0) { + extracted.push({ + orderId: orderId || `txn_${Date.now()}_${Math.random().toString(36).substring(7)}`, + dateText, + total, + description, + orderUrl: orderLink && href ? (href.startsWith('http') ? href : `https://www.amazon.com${href}`) : '', + }); + } + } catch (err) { + console.error('Error extracting transaction:', err); + } + }); + + currentElement = currentElement.nextElementSibling; + } + }); + + return extracted; + }); + + console.log(`Extracted ${transactions.length} transactions`); + + if (transactions.length > 0) { + console.log('First 3 transactions (raw data):'); + transactions.slice(0, 3).forEach((txn, i) => { + console.log(` ${i + 1}. Date: "${txn.dateText}", Amount: $${txn.total}, Desc: "${txn.description}"`); + }); + } else { + console.log('WARNING: No transactions were extracted from the page!'); + } + + // Process and filter transactions by date + for (const txn of transactions) { + try { + const txnDate = new Date(txn.dateText); + + // Check if date is valid and within range + if (!isNaN(txnDate.getTime()) && txnDate >= startDate && txnDate <= endDate) { + // Check if description suggests it's an Amazon order + const isAmazonOrder = txn.description.toLowerCase().includes('amazon') || + txn.description.toLowerCase().includes('order') || + txn.orderId; + + if (isAmazonOrder) { + orders.push({ + orderId: txn.orderId, + orderDate: txnDate, + total: txn.total, + items: [{ + title: txn.description || 'Amazon Purchase', + price: txn.total, + quantity: 1, + }], + orderUrl: txn.orderUrl || `https://www.amazon.com/cpe/yourpayments/transactions`, + }); + } + } + } catch (err) { + console.error('Error processing transaction:', err); + } + } + + console.log(`Found ${orders.length} Amazon orders within date range`); + + return orders; + } catch (error) { + console.error('Error scraping Amazon orders:', error); + throw error; + } finally { + await browser.close(); + } + } + + /** + * Fetch product details for matched orders + * @param orders - Array of Amazon orders that need product details + * @returns Updated orders with product information + */ + async fetchProductDetails(orders: AmazonOrder[]): Promise { + const browser = await puppeteer.launch({ + headless: false, + userDataDir: './.puppeteer_cache', + args: ['--window-size=1920,1080'], + }); + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + console.log(`Fetching product details for ${orders.length} orders...`); + + for (let i = 0; i < orders.length; i++) { + const order = orders[i]; + + // Skip if no order URL or no valid order ID + if (!order.orderUrl || !order.orderId || order.orderId.startsWith('txn_')) { + console.log(`Skipping order ${i + 1}/${orders.length} - no valid order ID`); + continue; + } + + try { + console.log(`Fetching details for order ${i + 1}/${orders.length}: ${order.orderId}`); + + // Navigate to order details page + await page.goto(order.orderUrl, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + + await this.wait(2000); + + // Save debug files for this order page + const safeOrderId = order.orderId.replace(/[^a-zA-Z0-9-]/g, '_'); + await page.screenshot({ path: `debug-order-${safeOrderId}.png`, fullPage: true }); + const html = await page.content(); + await Bun.write(`debug-order-${safeOrderId}.html`, html); + console.log(` Debug files saved: debug-order-${safeOrderId}.{png,html}`); + + // Extract product information from the order details page + const productDetails = await page.evaluate(() => { + const items: any[] = []; + + // Try multiple selectors for product items + const productElements = document.querySelectorAll( + '[data-component=purchasedItemsRightGrid]' + ); + + productElements.forEach((elem) => { + try { + // Get product title + const titleElem = elem.querySelector('[data-component=itemTitle]'); + const title = titleElem?.textContent?.trim() || ''; + + // Get price if available + const priceElem = elem.querySelector('[data-component=unitPrice]'); + const priceText = priceElem?.textContent?.trim() || ''; + const priceMatch = priceText.match(/\$\s*([\d,]+\.\d{2})/); + const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : 0; + + // Get quantity if available + const quantity = 1; + + if (title && title.length > 5) { + items.push({ title, price, quantity }); + } + } catch (err) { + console.error('Error extracting product:', err); + } + }); + + // If no products found, try alternative selectors + if (items.length === 0) { + const altProducts = document.querySelectorAll('.a-box-group'); + altProducts.forEach((box) => { + const titleElem = box.querySelector('a.a-link-normal, .a-text-bold'); + const title = titleElem?.textContent?.trim() || ''; + + if (title && title.length > 10 && !title.includes('Track package') && !title.includes('View order')) { + items.push({ title, price: 0, quantity: 1 }); + } + }); + } + + return items; + }); + + if (productDetails.length > 0) { + orders[i].items = productDetails; + console.log(` Found ${productDetails.length} products:`, productDetails.map(p => p.title).join(', ')); + } else { + console.log(` No products found, keeping merchant name`); + } + + // Small delay between requests to avoid rate limiting + await this.wait(1000); + + } catch (error) { + console.error(`Error fetching order details for ${order.orderId}:`, error); + // Keep the default items with merchant name + } + } + + console.log('Finished fetching product details'); + return orders; + + } catch (error) { + console.error('Error in fetchProductDetails:', error); + return orders; + } finally { + await browser.close(); + } + } + + /** + * Get order details for a specific order ID + * @param orderId - Amazon order ID + * @returns Order details + */ + async getOrderDetails(orderId: string): Promise { + const browser = await puppeteer.launch({ + headless: false, + userDataDir: './.puppeteer_cache', + }); + + try { + const page = await browser.newPage(); + await page.setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + + const orderUrl = `https://www.amazon.com/gp/your-account/order-details?orderID=${orderId}`; + await page.goto(orderUrl, { waitUntil: 'networkidle2' }); + + // Extract detailed order information + const orderDetails = await page.evaluate(() => { + const orderDateElement = document.querySelector('.order-date-invoice-item'); + const orderDateText = orderDateElement?.textContent?.trim() || ''; + + const totalElement = document.querySelector('.grand-total-price'); + const totalText = totalElement?.textContent?.trim().replace(/[^0-9.]/g, '') || '0'; + const total = parseFloat(totalText); + + const items: any[] = []; + const itemElements = document.querySelectorAll('.product'); + + itemElements.forEach((item) => { + const titleElement = item.querySelector('.product-title'); + const title = titleElement?.textContent?.trim() || ''; + + const priceElement = item.querySelector('.product-price'); + const priceText = priceElement?.textContent?.trim().replace(/[^0-9.]/g, '') || '0'; + const price = parseFloat(priceText); + + const quantityElement = item.querySelector('.quantity'); + const quantityText = quantityElement?.textContent?.trim().replace(/[^0-9]/g, '') || '1'; + const quantity = parseInt(quantityText); + + if (title) { + items.push({ title, price, quantity }); + } + }); + + return { + orderDateText, + total, + items, + }; + }); + + return { + orderId, + orderDate: new Date(orderDetails.orderDateText), + total: orderDetails.total, + items: orderDetails.items, + orderUrl, + }; + } catch (error) { + console.error(`Error getting details for order ${orderId}:`, error); + return null; + } finally { + await browser.close(); + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3731f75 --- /dev/null +++ b/bun.lock @@ -0,0 +1,240 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "ynab-amazon-helper", + "dependencies": { + "openai": "^6.7.0", + "puppeteer": "^24.28.0", + "ynab": "^2.10.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.10.13", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], + + "bare-events": ["bare-events@2.8.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ=="], + + "bare-fs": ["bare-fs@4.5.0", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-GljgCjeupKZJNetTqxKaQArLK10vpmK28or0+RwWjEl5Rk+/xG3wkpmkv+WrcBm3q1BwHKlnhXzR8O37kcvkXQ=="], + + "bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="], + + "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], + + "basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chromium-bidi": ["chromium-bidi@10.5.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1521046", "", {}, "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fetch-ponyfill": ["fetch-ponyfill@7.1.0", "", { "dependencies": { "node-fetch": "~2.6.1" } }, "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + + "node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openai": ["openai@6.7.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "puppeteer": ["puppeteer@24.28.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.13", "chromium-bidi": "10.5.1", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1521046", "puppeteer-core": "24.28.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-KLRGFNCGmXJpocEBbEIoHJB0vNRZLQNBjl5ExXEv0z7MIU+qqVEQcfWTyat+qxPDk/wZvSf+b30cQqAfWxX0zg=="], + + "puppeteer-core": ["puppeteer-core@24.28.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.13", "chromium-bidi": "10.5.1", "debug": "^4.4.3", "devtools-protocol": "0.0.1521046", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.3.8", "ws": "^8.18.3" } }, "sha512-QpAqaYgeZHF5/xAZ4jAOzsU+l0Ed4EJoWkRdfw8rNqmSN7itcdYeCJaSPQ0s5Pyn/eGNC4xNevxbgY+5bzNllw=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typed-query-selector": ["typed-query-selector@2.12.0", "", {}, "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.8", "", {}, "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "ynab": ["ynab@2.10.0", "", { "dependencies": { "fetch-ponyfill": "^7.1.0" } }, "sha512-zDH++4mbFpVDbDW1qIYS3pG3sPVtdth7c45aEfU7pVNqIcK6aVzK8eSksyOarrzaPKSq4OA9AXq1ixC7WGdpow=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/category-suggester.ts b/category-suggester.ts new file mode 100644 index 0000000..d379b2e --- /dev/null +++ b/category-suggester.ts @@ -0,0 +1,220 @@ +import OpenAI from 'openai'; +import type { AmazonOrder, YNABCategory, TransactionMatch } from './types'; + +export class CategorySuggester { + private openai: OpenAI; + + constructor(apiKey: string) { + this.openai = new OpenAI({ apiKey }); + } + + /** + * Suggest categories for transaction matches using OpenAI + * @param matches - Array of transaction matches + * @param categories - Available YNAB categories + * @returns Updated matches with suggested categories + */ + async suggestCategories( + matches: TransactionMatch[], + categories: YNABCategory[] + ): Promise { + const updatedMatches: TransactionMatch[] = []; + + for (const match of matches) { + if (!match.amazonOrder) { + updatedMatches.push(match); + continue; + } + + try { + const suggestedCategory = await this.suggestCategoryForOrder( + match.amazonOrder, + categories + ); + + updatedMatches.push({ + ...match, + suggestedCategory, + }); + } catch (error) { + console.error(`Error suggesting category for order ${match.amazonOrder.orderId}:`, error); + updatedMatches.push(match); + } + } + + return updatedMatches; + } + + /** + * Suggest a category for a single Amazon order + * @param order - Amazon order + * @param categories - Available YNAB categories + * @returns Suggested category or null + */ + private async suggestCategoryForOrder( + order: AmazonOrder, + categories: YNABCategory[] + ): Promise { + // Create a description of the order items + const itemDescriptions = order.items + .map((item) => `${item.title} (qty: ${item.quantity}, $${item.price.toFixed(2)})`) + .join('\n'); + + // Create a list of available categories + const categoryList = categories + .map((cat) => `${cat.category_group_name} > ${cat.name} (ID: ${cat.id})`) + .join('\n'); + + // Create the prompt for OpenAI + const prompt = `You are a financial categorization assistant. Based on the following Amazon order items, suggest the most appropriate budget category from the available categories list. + +Amazon Order Items: +${itemDescriptions} + +Available Budget Categories: +${categoryList} + +Instructions: +1. Analyze the product titles to understand what type of items were purchased +2. Select the single most appropriate category from the available list +3. Respond with ONLY the category ID (nothing else) +4. If no category seems appropriate, respond with "null" + +Category ID:`; + + try { + const response = await this.openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are a helpful assistant that categorizes purchases into budget categories. You always respond with just the category ID or "null".', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.3, + max_tokens: 100, + }); + + const categoryId = response.choices[0]?.message?.content?.trim(); + + if (!categoryId || categoryId === 'null') { + console.warn(`Could not match "${itemDescriptions}"`) + return null; + } + + // Find and return the category + const category = categories.find((cat) => cat.id === categoryId); + return category || null; + } catch (error) { + console.error('Error calling OpenAI API:', error); + return null; + } + } + + /** + * Suggest a category with explanation (for debugging/testing) + * @param order - Amazon order + * @param categories - Available YNAB categories + * @returns Suggested category with explanation + */ + async suggestCategoryWithExplanation( + order: AmazonOrder, + categories: YNABCategory[] + ): Promise<{ category: YNABCategory | null; explanation: string }> { + const itemDescriptions = order.items + .map((item) => `${item.title} (qty: ${item.quantity}, $${item.price.toFixed(2)})`) + .join('\n'); + + const categoryList = categories + .map((cat) => `${cat.category_group_name} > ${cat.name} (ID: ${cat.id})`) + .join('\n'); + + const prompt = `You are a financial categorization assistant. Based on the following Amazon order items, suggest the most appropriate budget category from the available categories list. + +Amazon Order Items: +${itemDescriptions} + +Available Budget Categories: +${categoryList} + +Instructions: +1. Analyze the product titles to understand what type of items were purchased +2. Select the single most appropriate category from the available list +3. Respond in JSON format: {"categoryId": "the-category-id", "explanation": "brief explanation of why this category fits"} +4. If no category seems appropriate, use null for categoryId + +Response:`; + + try { + const response = await this.openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are a helpful assistant that categorizes purchases into budget categories. You always respond with valid JSON.', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.3, + max_tokens: 200, + }); + + const content = response.choices[0]?.message?.content?.trim() || '{}'; + const parsed = JSON.parse(content); + + const category = parsed.categoryId + ? categories.find((cat) => cat.id === parsed.categoryId) || null + : null; + + return { + category, + explanation: parsed.explanation || 'No explanation provided', + }; + } catch (error) { + console.error('Error calling OpenAI API:', error); + return { + category: null, + explanation: 'Error generating suggestion', + }; + } + } + + /** + * Batch suggest categories for multiple orders (more efficient) + * @param orders - Array of Amazon orders + * @param categories - Available YNAB categories + * @returns Map of order ID to suggested category + */ + async batchSuggestCategories( + orders: AmazonOrder[], + categories: YNABCategory[] + ): Promise> { + const suggestions = new Map(); + + // Process in batches of 5 to avoid rate limits + const batchSize = 5; + for (let i = 0; i < orders.length; i += batchSize) { + const batch = orders.slice(i, i + batchSize); + const batchPromises = batch.map(async (order) => { + const category = await this.suggestCategoryForOrder(order, categories); + suggestions.set(order.orderId, category); + }); + + await Promise.all(batchPromises); + + // Small delay between batches to respect rate limits + if (i + batchSize < orders.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return suggestions; + } +} diff --git a/frontend.tsx b/frontend.tsx new file mode 100644 index 0000000..c86257d --- /dev/null +++ b/frontend.tsx @@ -0,0 +1,332 @@ +import type { TransactionMatch, YNABCategory, APIResponse } from './types'; + +let currentSessionId: string | null = null; +let matches: TransactionMatch[] = []; +let categories: YNABCategory[] = []; + +// Initialize date inputs with default values (last 30 days) +const endDate = new Date(); +const startDate = new Date(); +startDate.setDate(startDate.getDate() - 30); + +const startDateInput = document.getElementById('startDate') as HTMLInputElement; +const endDateInput = document.getElementById('endDate') as HTMLInputElement; +const useUnapprovedCheckbox = document.getElementById('useUnapproved') as HTMLInputElement; +const dateRangeSection = document.getElementById('dateRangeSection') as HTMLDivElement; + +startDateInput.value = startDate.toISOString().split('T')[0]; +endDateInput.value = endDate.toISOString().split('T')[0]; + +// Fetch categories on page load +fetchCategories(); + +// Event listeners +document.getElementById('fetchBtn')?.addEventListener('click', fetchMatches); +document.getElementById('approveBtn')?.addEventListener('click', approveMatches); + +// Toggle date range visibility based on checkbox +useUnapprovedCheckbox.addEventListener('change', () => { + if (useUnapprovedCheckbox.checked) { + dateRangeSection.style.display = 'none'; + } else { + dateRangeSection.style.display = 'block'; + } +}); + +async function fetchCategories() { + try { + const response = await fetch('/api/categories'); + const data: APIResponse = await response.json(); + + if (data.success && data.data) { + categories = data.data; + console.log(`Loaded ${categories.length} categories`); + } + } catch (error) { + console.error('Error fetching categories:', error); + } +} + +async function fetchMatches() { + const useUnapproved = useUnapprovedCheckbox.checked; + const startDate = (document.getElementById('startDate') as HTMLInputElement).value; + const endDate = (document.getElementById('endDate') as HTMLInputElement).value; + + // Validate inputs + if (!useUnapproved && (!startDate || !endDate)) { + showError('Please select both start and end dates or use unapproved transactions'); + return; + } + + // Show loading state + showSection('loading'); + hideError(); + + try { + const body: any = { useUnapproved }; + if (!useUnapproved) { + body.startDate = startDate; + body.endDate = endDate; + } + + const response = await fetch('/api/fetch-matches', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data: APIResponse<{ sessionId: string; stats: any }> = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to fetch matches'); + } + + currentSessionId = data.data!.sessionId; + + // Display statistics + displayStats(data.data!.stats); + + // Fetch the matches + await loadMatches(); + + // Show matches section + showSection('matches'); + } catch (error) { + showSection('error'); + showError(error instanceof Error ? error.message : 'An error occurred'); + } +} + +async function loadMatches() { + if (!currentSessionId) return; + + try { + const response = await fetch(`/api/matches/${currentSessionId}`); + const data: APIResponse = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load matches'); + } + + matches = data.data!; + displayMatches(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Failed to load matches'); + } +} + +function displayStats(stats: any) { + const statsContainer = document.getElementById('stats')!; + statsContainer.innerHTML = ` +
+
Total Transactions
+
${stats.total}
+
+
+
Matched
+
${stats.matched}
+
+
+
High Confidence
+
${stats.highConfidence}
+
+
+
Medium Confidence
+
${stats.mediumConfidence}
+
+
+
Low Confidence
+
${stats.lowConfidence}
+
+ `; +} + +function displayMatches() { + const matchesList = document.getElementById('matchesList')!; + matchesList.innerHTML = ''; + + matches.forEach((match, index) => { + const card = createMatchCard(match, index); + matchesList.appendChild(card); + }); +} + +function createMatchCard(match: TransactionMatch, index: number): HTMLDivElement { + const card = document.createElement('div'); + card.className = `match-card ${match.status === 'approved' ? 'status-approved' : ''} ${match.status === 'rejected' ? 'status-rejected' : ''} ${!match.amazonOrder ? 'no-match' : ''}`; + + const amount = Math.abs(match.ynabTransaction.amount / 1000); + const confidenceClass = + match.matchConfidence >= 0.8 + ? 'confidence-high' + : match.matchConfidence >= 0.5 + ? 'confidence-medium' + : 'confidence-low'; + + const confidencePercent = Math.round(match.matchConfidence * 100); + + card.innerHTML = ` +
+
+
${match.ynabTransaction.date} • ${match.ynabTransaction.account_name}
+
${match.ynabTransaction.payee_name || 'Unknown Payee'}
+
$${amount.toFixed(2)}
+ ${match.ynabTransaction.memo ? `
${match.ynabTransaction.memo}
` : ''} +
+ ${match.amazonOrder ? `
${confidencePercent}% Match
` : '
No Match
'} +
+ + ${match.amazonOrder ? ` +
+
Amazon Order #${match.amazonOrder.orderId}
+
+ ${match.amazonOrder.items.map(item => `• ${item.title} (${item.quantity}x $${item.price.toFixed(2)})`).join('
')} +
+
Total: $${match.amazonOrder.total.toFixed(2)}
+ View on Amazon → +
+ +
+ + +
+ +
+ + +
+ ` : ` +
+ No matching Amazon order found within the date and amount tolerance. +
+ `} + `; + + return card; +} + +function renderCategoryOptions(suggestedId?: string): string { + const grouped = new Map(); + + categories.forEach((cat) => { + if (!grouped.has(cat.category_group_name)) { + grouped.set(cat.category_group_name, []); + } + grouped.get(cat.category_group_name)!.push(cat); + }); + + let html = ''; + grouped.forEach((cats, groupName) => { + html += ``; + cats.forEach((cat) => { + const selected = cat.id === suggestedId ? 'selected' : ''; + html += ``; + }); + html += ''; + }); + + return html; +} + +(window as any).updateMatchStatus = async (index: number, status: 'approved' | 'rejected') => { + if (!currentSessionId) return; + + // Get the selected category + const categorySelect = document.getElementById(`category-${index}`) as HTMLSelectElement; + const categoryId = categorySelect?.value || null; + const suggestedCategory = categoryId + ? categories.find((c) => c.id === categoryId) || null + : null; + + try { + const response = await fetch(`/api/matches/${currentSessionId}/${index}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ status, suggestedCategory }), + }); + + const data: APIResponse = await response.json(); + + if (data.success && data.data) { + matches[index] = data.data; + displayMatches(); + } + } catch (error) { + console.error('Error updating match status:', error); + showError('Failed to update match status'); + } +}; + +async function approveMatches() { + if (!currentSessionId) return; + + const approvedCount = matches.filter((m) => m.status === 'approved').length; + + if (approvedCount === 0) { + showError('No matches approved. Please approve at least one match before applying.'); + return; + } + + if (!confirm(`Apply ${approvedCount} approved match(es) to YNAB?`)) { + return; + } + + showSection('loading'); + + try { + const response = await fetch(`/api/approve-matches/${currentSessionId}`, { + method: 'POST', + }); + + const data: APIResponse = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to apply matches'); + } + + const results = data.data!; + const successCount = results.filter((r) => r.success).length; + const failCount = results.length - successCount; + + alert( + `Successfully updated ${successCount} transaction(s)!${ + failCount > 0 ? `\n${failCount} failed.` : '' + }` + ); + + showSection('matches'); + } catch (error) { + showSection('matches'); + showError(error instanceof Error ? error.message : 'Failed to apply matches'); + } +} + +function showSection(section: 'loading' | 'error' | 'matches' | 'stats') { + document.getElementById('loadingSection')!.style.display = section === 'loading' ? 'block' : 'none'; + document.getElementById('errorSection')!.style.display = section === 'error' ? 'block' : 'none'; + document.getElementById('matchesSection')!.style.display = section === 'matches' ? 'block' : 'none'; + document.getElementById('statsSection')!.style.display = section === 'stats' || section === 'matches' ? 'block' : 'none'; +} + +function showError(message: string) { + const errorMessage = document.getElementById('errorMessage')!; + errorMessage.textContent = message; + document.getElementById('errorSection')!.style.display = 'block'; +} + +function hideError() { + document.getElementById('errorSection')!.style.display = 'none'; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3b68409 --- /dev/null +++ b/index.html @@ -0,0 +1,398 @@ + + + + + + YNAB Amazon Helper + + + +
+
+

YNAB Amazon Helper

+

Link your YNAB transactions to Amazon orders with AI-powered categorization

+
+ +
+
+ +

+ Automatically fetch all unapproved transactions from YNAB without requiring a date range. +

+
+ + + +
+ +
+ + + + + + + + +
+
+ + + + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/inspect-order.ts b/inspect-order.ts new file mode 100644 index 0000000..d7ea457 --- /dev/null +++ b/inspect-order.ts @@ -0,0 +1,115 @@ +/** + * Helper script to inspect a single Amazon order page + * Usage: bun inspect-order.ts + */ + +import puppeteer from 'puppeteer'; + +const orderId = process.argv[2] || '112-4052475-6569056'; + +console.log(`Inspecting order: ${orderId}`); + +const browser = await puppeteer.launch({ + headless: false, + userDataDir: './.puppeteer_cache', + args: ['--window-size=1920,1080'], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // Navigate to order details page + const orderUrl = `https://www.amazon.com/gp/your-account/order-details?orderID=${orderId}`; + console.log(`Navigating to: ${orderUrl}`); + + await page.goto(orderUrl, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Save debug files + await page.screenshot({ path: `debug-order-${orderId}.png`, fullPage: true }); + const html = await page.content(); + await Bun.write(`debug-order-${orderId}.html`, html); + console.log(`\nDebug files saved: debug-order-${orderId}.{png,html}`); + + // Analyze the page structure + const analysis = await page.evaluate(() => { + // Look for all class names that might be relevant + const allElements = document.querySelectorAll('*'); + const classNames = new Set(); + const dataTestIds = new Set(); + + allElements.forEach(elem => { + if (elem.className && typeof elem.className === 'string') { + elem.className.split(' ').forEach(cls => { + if (cls.toLowerCase().includes('product') || + cls.toLowerCase().includes('item') || + cls.toLowerCase().includes('shipment') || + cls.toLowerCase().includes('order')) { + classNames.add(cls); + } + }); + } + + const testId = elem.getAttribute('data-testid'); + if (testId) { + dataTestIds.add(testId); + } + }); + + // Try to find product-related elements + const productSelectors = [ + '.shipment .product', + '.a-box-group .a-fixed-left-grid', + '[data-asin]', + '.product-title', + '.a-link-normal', + '.a-size-medium', + '.yohtmlc-product-title', + '.yohtmlc-item', + ]; + + const foundElements: any = {}; + productSelectors.forEach(selector => { + const elements = document.querySelectorAll(selector); + if (elements.length > 0) { + foundElements[selector] = { + count: elements.length, + samples: Array.from(elements).slice(0, 3).map(el => ({ + tag: el.tagName, + text: el.textContent?.trim().substring(0, 100), + classes: el.className, + })) + }; + } + }); + + return { + classNames: Array.from(classNames).sort(), + dataTestIds: Array.from(dataTestIds).sort(), + foundElements, + pageTitle: document.title, + }; + }); + + console.log('\n=== Page Analysis ==='); + console.log('Page title:', analysis.pageTitle); + console.log('\nRelevant class names:', analysis.classNames.length); + console.log(analysis.classNames.slice(0, 20)); + console.log('\nData-testid attributes:', analysis.dataTestIds.length); + console.log(analysis.dataTestIds.slice(0, 10)); + console.log('\n=== Elements Found ==='); + console.log(JSON.stringify(analysis.foundElements, null, 2)); + + console.log('\n\nPress Ctrl+C to close the browser and exit'); + await new Promise(() => {}); // Keep browser open + +} catch (error) { + console.error('Error:', error); +} finally { + await browser.close(); +} diff --git a/inspect-page.ts b/inspect-page.ts new file mode 100644 index 0000000..c84c0cc --- /dev/null +++ b/inspect-page.ts @@ -0,0 +1,56 @@ +/** + * Helper script to inspect the Amazon transactions page HTML + * Run this to see what selectors we should use + */ + +const html = await Bun.file('debug-transactions-page.html').text(); + +// Find all unique class names that might be relevant +const classRegex = /class="([^"]+)"/g; +const classes = new Set(); +let match; + +while ((match = classRegex.exec(html)) !== null) { + const classList = match[1].split(' '); + classList.forEach(cls => { + if (cls.toLowerCase().includes('transaction') || + cls.toLowerCase().includes('payment') || + cls.toLowerCase().includes('order') || + cls.toLowerCase().includes('item') || + cls.toLowerCase().includes('amount') || + cls.toLowerCase().includes('date')) { + classes.add(cls); + } + }); +} + +console.log('\n=== Relevant Class Names Found ==='); +console.log(Array.from(classes).sort().join('\n')); + +// Find data-testid attributes +const testIdRegex = /data-testid="([^"]+)"/g; +const testIds = new Set(); + +while ((match = testIdRegex.exec(html)) !== null) { + testIds.add(match[1]); +} + +console.log('\n=== Data-testid Attributes Found ==='); +console.log(Array.from(testIds).sort().join('\n')); + +// Look for common patterns +console.log('\n=== Page Analysis ==='); +console.log(`Total HTML length: ${html.length} characters`); +console.log(`Contains "transaction": ${html.toLowerCase().includes('transaction')}`); +console.log(`Contains "payment": ${html.toLowerCase().includes('payment')}`); +console.log(`Contains "order": ${html.toLowerCase().includes('order')}`); + +// Try to find price/amount patterns +const priceRegex = /\$\d+\.\d{2}/g; +const prices = html.match(priceRegex) || []; +console.log(`\nFound ${prices.length} price patterns (e.g., $XX.XX)`); +if (prices.length > 0) { + console.log(`First 10 prices: ${prices.slice(0, 10).join(', ')}`); +} + +console.log('\nCheck debug-transactions-page.html to see the full HTML structure'); diff --git a/matcher.ts b/matcher.ts new file mode 100644 index 0000000..1d204a8 --- /dev/null +++ b/matcher.ts @@ -0,0 +1,188 @@ +import type { AmazonOrder, YNABTransaction, TransactionMatch, MatchingOptions } from './types'; +import { YNABClient } from './ynab-client'; + +export class TransactionMatcher { + private options: MatchingOptions; + + constructor(options: Partial = {}) { + this.options = { + dateToleranceDays: options.dateToleranceDays ?? 3, + amountToleranceDollars: options.amountToleranceDollars ?? 0.5, + }; + } + + /** + * Match YNAB transactions with Amazon orders + * @param ynabTransactions - Array of YNAB transactions + * @param amazonOrders - Array of Amazon orders + * @returns Array of transaction matches + */ + matchTransactions( + ynabTransactions: YNABTransaction[], + amazonOrders: AmazonOrder[] + ): TransactionMatch[] { + const matches: TransactionMatch[] = []; + const usedOrderIds = new Set(); + + // Sort both arrays by date for efficient matching + const sortedTransactions = [...ynabTransactions].sort((a, b) => + new Date(a.date).getTime() - new Date(b.date).getTime() + ); + const sortedOrders = [...amazonOrders].sort((a, b) => + a.orderDate.getTime() - b.orderDate.getTime() + ); + + for (const transaction of sortedTransactions) { + const transactionDate = new Date(transaction.date); + const transactionAmount = Math.abs(YNABClient.milliunitsToDollars(transaction.amount)); + + let bestMatch: AmazonOrder | null = null; + let bestConfidence = 0; + + // Find potential matches within date range + for (const order of sortedOrders) { + // Skip already matched orders + if (usedOrderIds.has(order.orderId)) continue; + + // Check date proximity + const daysDifference = this.getDaysDifference(transactionDate, order.orderDate); + if (daysDifference > this.options.dateToleranceDays) continue; + + // Check amount proximity + const amountDifference = Math.abs(transactionAmount - order.total); + if (amountDifference > this.options.amountToleranceDollars) continue; + + // Calculate confidence score + const confidence = this.calculateConfidence( + transactionDate, + order.orderDate, + transactionAmount, + order.total, + transaction.payee_name, + order + ); + + if (confidence > bestConfidence) { + bestConfidence = confidence; + bestMatch = order; + } + } + + // Create match entry + if (bestMatch) { + usedOrderIds.add(bestMatch.orderId); + } + + matches.push({ + ynabTransaction: transaction, + amazonOrder: bestMatch, + matchConfidence: bestConfidence, + suggestedCategory: null, // Will be filled in by OpenAI + status: 'pending', + }); + } + + return matches; + } + + /** + * Calculate confidence score for a potential match + * @param transactionDate - YNAB transaction date + * @param orderDate - Amazon order date + * @param transactionAmount - YNAB transaction amount in dollars + * @param orderAmount - Amazon order amount in dollars + * @param payeeName - YNAB payee name + * @param order - Amazon order + * @returns Confidence score (0-1) + */ + private calculateConfidence( + transactionDate: Date, + orderDate: Date, + transactionAmount: number, + orderAmount: number, + payeeName: string | null, + order: AmazonOrder + ): number { + let score = 0; + + // Amount matching (50% weight) + const amountDifference = Math.abs(transactionAmount - orderAmount); + const amountScore = Math.max(0, 1 - (amountDifference / this.options.amountToleranceDollars)); + score += amountScore * 0.5; + + // Date matching (30% weight) + const daysDifference = this.getDaysDifference(transactionDate, orderDate); + const dateScore = Math.max(0, 1 - (daysDifference / this.options.dateToleranceDays)); + score += dateScore * 0.3; + + // Payee name matching (20% weight) + if (payeeName) { + const normalizedPayee = payeeName.toLowerCase(); + if ( + normalizedPayee.includes('amazon') || + normalizedPayee.includes('amzn') + ) { + score += 0.2; + } + } + + return Math.min(1, score); + } + + /** + * Get the difference in days between two dates + * @param date1 - First date + * @param date2 - Second date + * @returns Absolute difference in days + */ + private getDaysDifference(date1: Date, date2: Date): number { + const msPerDay = 24 * 60 * 60 * 1000; + return Math.abs((date1.getTime() - date2.getTime()) / msPerDay); + } + + /** + * Filter matches by minimum confidence threshold + * @param matches - Array of transaction matches + * @param minConfidence - Minimum confidence threshold (0-1) + * @returns Filtered matches + */ + filterByConfidence( + matches: TransactionMatch[], + minConfidence: number + ): TransactionMatch[] { + return matches.filter((match) => match.matchConfidence >= minConfidence); + } + + /** + * Get statistics about the matches + * @param matches - Array of transaction matches + * @returns Match statistics + */ + getMatchStatistics(matches: TransactionMatch[]): { + total: number; + matched: number; + unmatched: number; + highConfidence: number; + mediumConfidence: number; + lowConfidence: number; + } { + const total = matches.length; + const matched = matches.filter((m) => m.amazonOrder !== null).length; + const unmatched = total - matched; + + const highConfidence = matches.filter((m) => m.matchConfidence >= 0.8).length; + const mediumConfidence = matches.filter( + (m) => m.matchConfidence >= 0.5 && m.matchConfidence < 0.8 + ).length; + const lowConfidence = matches.filter((m) => m.matchConfidence < 0.5).length; + + return { + total, + matched, + unmatched, + highConfidence, + mediumConfidence, + lowConfidence, + }; + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..5c3c597 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +bun = "1.3.1" diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1acee2 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "ynab-amazon-helper", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "openai": "^6.7.0", + "puppeteer": "^24.28.0", + "ynab": "^2.10.0" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..fcf0f92 --- /dev/null +++ b/server.ts @@ -0,0 +1,291 @@ +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}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..dad0bf3 --- /dev/null +++ b/types.ts @@ -0,0 +1,55 @@ +// Amazon Order Types +export interface AmazonOrder { + orderId: string; + orderDate: Date; + total: number; + items: AmazonOrderItem[]; + orderUrl: string; +} + +export interface AmazonOrderItem { + title: string; + price: number; + quantity: number; +} + +// YNAB Transaction Types +export interface YNABTransaction { + id: string; + date: string; // YYYY-MM-DD format + amount: number; // in milliunits (e.g., -12340 = -$12.34) + payee_name: string | null; + memo: string | null; + category_id: string | null; + category_name: string | null; + account_id: string; + account_name: string; +} + +export interface YNABCategory { + id: string; + name: string; + category_group_id: string; + category_group_name: string; +} + +// Matching Types +export interface TransactionMatch { + ynabTransaction: YNABTransaction; + amazonOrder: AmazonOrder | null; + matchConfidence: number; // 0-1 score + suggestedCategory: YNABCategory | null; + status: 'pending' | 'approved' | 'rejected'; +} + +export interface MatchingOptions { + dateToleranceDays: number; // How many days before/after to consider a match + amountToleranceDollars: number; // How much variance in amount to allow +} + +// API Response Types +export interface APIResponse { + success: boolean; + data?: T; + error?: string; +} diff --git a/ynab-client.ts b/ynab-client.ts new file mode 100644 index 0000000..2c6c01f --- /dev/null +++ b/ynab-client.ts @@ -0,0 +1,207 @@ +import * as ynab from 'ynab'; +import type { YNABTransaction, YNABCategory } from './types'; + +export class YNABClient { + private api: ynab.API; + private budgetId: string; + + constructor(accessToken: string, budgetId: string) { + this.api = new ynab.API(accessToken); + this.budgetId = budgetId; + } + + /** + * Fetch unapproved transactions + * @returns Array of unapproved YNAB transactions + */ + async getUnapprovedTransactions(): Promise { + try { + const response = await this.api.transactions.getTransactions( + this.budgetId, + undefined, // sinceDate - get all + 'unapproved' // type filter for unapproved transactions + ); + + const transactions = response.data.transactions + .filter((t) => { + // Only include outflow transactions (purchases) + return t.amount < 0; + }) + .map((t) => this.mapTransaction(t)); + + return transactions; + } catch (error) { + console.error('Error fetching unapproved YNAB transactions:', error); + throw error; + } + } + + /** + * Fetch transactions for a given date range + * @param startDate - Start date (YYYY-MM-DD) + * @param endDate - End date (YYYY-MM-DD) + * @returns Array of YNAB transactions + */ + async getTransactions(startDate: string, endDate?: string): Promise { + try { + const response = await this.api.transactions.getTransactions( + this.budgetId, + startDate, + undefined // type filter + ); + + const transactions = response.data.transactions + .filter((t) => { + // Filter by date range if endDate is provided + if (endDate && t.date > endDate) { + return false; + } + // Only include outflow transactions (purchases) + return t.amount < 0; + }) + .map((t) => this.mapTransaction(t)); + + return transactions; + } catch (error) { + console.error('Error fetching YNAB transactions:', error); + throw error; + } + } + + /** + * Get all budget categories + * @returns Array of YNAB categories + */ + async getCategories(): Promise { + try { + const response = await this.api.categories.getCategories(this.budgetId); + + const categories: YNABCategory[] = []; + + for (const group of response.data.category_groups) { + if (group.hidden || group.deleted) continue; + + for (const category of group.categories) { + if (category.hidden || category.deleted) continue; + + categories.push({ + id: category.id, + name: category.name, + category_group_id: group.id, + category_group_name: group.name, + }); + } + } + + return categories; + } catch (error) { + console.error('Error fetching YNAB categories:', error); + throw error; + } + } + + /** + * Update a transaction's memo, category, and approval status + * @param transactionId - Transaction ID + * @param memo - New memo text + * @param categoryId - Optional category ID + * @param approved - Whether to mark as approved (default: true) + */ + async updateTransaction( + transactionId: string, + memo: string, + categoryId?: string, + approved: boolean = true + ): Promise { + try { + const updateData: ynab.SaveTransaction = { + memo, + approved, + }; + + if (categoryId) { + updateData.category_id = categoryId; + } + + await this.api.transactions.updateTransaction( + this.budgetId, + transactionId, + { transaction: updateData } + ); + + console.log(`Updated transaction ${transactionId}${approved ? ' and marked as approved' : ''}`); + } catch (error) { + console.error(`Error updating transaction ${transactionId}:`, error); + throw error; + } + } + + /** + * Get account name for a transaction + * @param accountId - Account ID + * @returns Account name + */ + async getAccountName(accountId: string): Promise { + try { + const response = await this.api.accounts.getAccounts(this.budgetId); + const account = response.data.accounts.find((a) => a.id === accountId); + return account?.name || 'Unknown Account'; + } catch (error) { + console.error('Error fetching account name:', error); + return 'Unknown Account'; + } + } + + /** + * Get category name for a transaction + * @param categoryId - Category ID + * @returns Category name + */ + async getCategoryName(categoryId: string | null): Promise { + if (!categoryId) return null; + + try { + const categories = await this.getCategories(); + const category = categories.find((c) => c.id === categoryId); + return category?.name || null; + } catch (error) { + console.error('Error fetching category name:', error); + return null; + } + } + + /** + * Map YNAB API transaction to our internal format + */ + private mapTransaction(transaction: ynab.TransactionDetail): YNABTransaction { + return { + id: transaction.id, + date: transaction.date, + amount: transaction.amount, + payee_name: transaction.payee_name, + memo: transaction.memo, + category_id: transaction.category_id, + category_name: transaction.category_name, + account_id: transaction.account_id, + account_name: transaction.account_name, + }; + } + + /** + * Convert YNAB milliunits to dollars + * @param milliunits - Amount in milliunits + * @returns Amount in dollars + */ + static milliunitsToDollars(milliunits: number): number { + return milliunits / 1000; + } + + /** + * Convert dollars to YNAB milliunits + * @param dollars - Amount in dollars + * @returns Amount in milliunits + */ + static dollarsToMilliunits(dollars: number): number { + return Math.round(dollars * 1000); + } +}