vibe coding

This commit is contained in:
sloane 2025-11-04 12:26:58 -05:00
commit f93bc87042
No known key found for this signature in database
20 changed files with 3043 additions and 0 deletions

10
.env.example Normal file
View file

@ -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

39
.gitignore vendored Normal file
View file

@ -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

106
CLAUDE.md Normal file
View file

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
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 <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

207
README.md Normal file
View file

@ -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

106
TROUBLESHOOTING.md Normal file
View file

@ -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!

424
amazon-scraper.ts Normal file
View file

@ -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<void> {
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<AmazonOrder[]> {
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<string>();
const dataTestIds = new Set<string>();
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<AmazonOrder[]> {
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<AmazonOrder | null> {
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();
}
}
}

240
bun.lock Normal file
View file

@ -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=="],
}
}

220
category-suggester.ts Normal file
View file

@ -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<TransactionMatch[]> {
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<YNABCategory | null> {
// 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<Map<string, YNABCategory | null>> {
const suggestions = new Map<string, YNABCategory | null>();
// 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;
}
}

332
frontend.tsx Normal file
View file

@ -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<YNABCategory[]> = 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<TransactionMatch[]> = 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 = `
<div class="stat-card">
<div class="stat-label">Total Transactions</div>
<div class="stat-value">${stats.total}</div>
</div>
<div class="stat-card">
<div class="stat-label">Matched</div>
<div class="stat-value">${stats.matched}</div>
</div>
<div class="stat-card">
<div class="stat-label">High Confidence</div>
<div class="stat-value">${stats.highConfidence}</div>
</div>
<div class="stat-card">
<div class="stat-label">Medium Confidence</div>
<div class="stat-value">${stats.mediumConfidence}</div>
</div>
<div class="stat-card">
<div class="stat-label">Low Confidence</div>
<div class="stat-value">${stats.lowConfidence}</div>
</div>
`;
}
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 = `
<div class="match-header">
<div class="match-info">
<div class="match-date">${match.ynabTransaction.date} ${match.ynabTransaction.account_name}</div>
<div class="match-payee">${match.ynabTransaction.payee_name || 'Unknown Payee'}</div>
<div class="match-amount">$${amount.toFixed(2)}</div>
${match.ynabTransaction.memo ? `<div style="font-size: 0.9rem; color: #64748b; margin-top: 5px;">${match.ynabTransaction.memo}</div>` : ''}
</div>
${match.amazonOrder ? `<div class="confidence-badge ${confidenceClass}">${confidencePercent}% Match</div>` : '<div class="confidence-badge confidence-low">No Match</div>'}
</div>
${match.amazonOrder ? `
<div class="amazon-order">
<div class="order-header">Amazon Order #${match.amazonOrder.orderId}</div>
<div class="order-items">
${match.amazonOrder.items.map(item => `${item.title} (${item.quantity}x $${item.price.toFixed(2)})`).join('<br>')}
</div>
<div>Total: $${match.amazonOrder.total.toFixed(2)}</div>
<a href="${match.amazonOrder.orderUrl}" target="_blank" class="order-link">View on Amazon </a>
</div>
<div class="category-select">
<label style="font-size: 0.9rem; margin-bottom: 8px; display: block; color: #334155;">
<strong>Category:</strong> ${match.suggestedCategory ? '✨ AI Suggested' : 'Select manually'}
</label>
<select id="category-${index}">
<option value="">-- No Category Change --</option>
${renderCategoryOptions(match.suggestedCategory?.id)}
</select>
</div>
<div class="match-actions">
<button class="btn-small btn-approve" onclick="updateMatchStatus(${index}, 'approved')">
${match.status === 'approved' ? '✓ Approved' : 'Approve'}
</button>
<button class="btn-small btn-reject" onclick="updateMatchStatus(${index}, 'rejected')">
${match.status === 'rejected' ? '✗ Rejected' : 'Reject'}
</button>
</div>
` : `
<div style="color: #64748b; font-style: italic; margin-top: 10px;">
No matching Amazon order found within the date and amount tolerance.
</div>
`}
`;
return card;
}
function renderCategoryOptions(suggestedId?: string): string {
const grouped = new Map<string, YNABCategory[]>();
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 += `<optgroup label="${groupName}">`;
cats.forEach((cat) => {
const selected = cat.id === suggestedId ? 'selected' : '';
html += `<option value="${cat.id}" ${selected}>${cat.name}</option>`;
});
html += '</optgroup>';
});
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<TransactionMatch> = 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<any[]> = 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';
}

398
index.html Normal file
View file

@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YNAB Amazon Helper</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
header {
background: #1e293b;
color: white;
padding: 30px;
text-align: center;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.subtitle {
color: #94a3b8;
font-size: 0.95rem;
}
.section {
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #334155;
}
input[type="date"] {
width: 100%;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
input[type="date"]:focus {
outline: none;
border-color: #667eea;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 20px;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-1px);
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
transform: translateY(-1px);
}
.btn-primary:disabled, .btn-success:disabled {
background: #cbd5e1;
cursor: not-allowed;
transform: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
}
.stat-card {
background: #f8fafc;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.stat-label {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 5px;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
color: #1e293b;
}
.matches-list {
margin-top: 30px;
}
.match-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
transition: all 0.2s;
}
.match-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.match-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.match-info {
flex: 1;
}
.match-date {
font-size: 0.9rem;
color: #64748b;
margin-bottom: 5px;
}
.match-payee {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 5px;
}
.match-amount {
font-size: 1.3rem;
font-weight: 700;
color: #dc2626;
}
.confidence-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.confidence-high {
background: #d1fae5;
color: #065f46;
}
.confidence-medium {
background: #fef3c7;
color: #92400e;
}
.confidence-low {
background: #fee2e2;
color: #991b1b;
}
.amazon-order {
background: white;
border-left: 4px solid #ff9900;
padding: 15px;
margin-top: 15px;
border-radius: 4px;
}
.order-header {
font-weight: 600;
color: #1e293b;
margin-bottom: 10px;
}
.order-items {
font-size: 0.9rem;
color: #475569;
margin-bottom: 10px;
}
.order-link {
color: #667eea;
text-decoration: none;
font-size: 0.85rem;
}
.order-link:hover {
text-decoration: underline;
}
.category-select {
margin-top: 15px;
padding: 10px;
background: white;
border-radius: 6px;
}
select {
width: 100%;
padding: 10px;
border: 2px solid #e2e8f0;
border-radius: 4px;
font-size: 0.95rem;
}
.match-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-small {
padding: 8px 16px;
font-size: 0.9rem;
}
.btn-approve {
background: #10b981;
color: white;
}
.btn-approve:hover {
background: #059669;
}
.btn-reject {
background: #ef4444;
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
.status-approved {
border-color: #10b981;
background: #f0fdf4;
}
.status-rejected {
border-color: #ef4444;
background: #fef2f2;
opacity: 0.6;
}
.loading {
text-align: center;
padding: 40px;
color: #64748b;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #fef2f2;
border: 2px solid #ef4444;
color: #991b1b;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.no-match {
opacity: 0.5;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>YNAB Amazon Helper</h1>
<p class="subtitle">Link your YNAB transactions to Amazon orders with AI-powered categorization</p>
</header>
<div class="section">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="useUnapproved" checked style="width: auto; cursor: pointer;">
<span>Use Unapproved Transactions Only (Recommended)</span>
</label>
<p style="font-size: 0.85rem; color: #64748b; margin-top: 8px; margin-left: 30px;">
Automatically fetch all unapproved transactions from YNAB without requiring a date range.
</p>
</div>
<div id="dateRangeSection" style="display: none;">
<div class="form-group">
<label for="startDate">Start Date</label>
<input type="date" id="startDate">
</div>
<div class="form-group">
<label for="endDate">End Date</label>
<input type="date" id="endDate">
</div>
</div>
<div class="button-group">
<button class="btn-primary" id="fetchBtn">Fetch & Match Transactions</button>
</div>
<div id="statsSection" style="display: none;">
<h2 style="margin: 30px 0 15px; color: #1e293b;">Match Statistics</h2>
<div class="stats" id="stats"></div>
</div>
<div id="loadingSection" style="display: none;">
<div class="loading">
<div class="spinner"></div>
<p>Fetching transactions and matching with Amazon orders...</p>
<p style="font-size: 0.9rem; margin-top: 10px;">This may take a few minutes.</p>
</div>
</div>
<div id="errorSection" style="display: none;">
<div class="error" id="errorMessage"></div>
</div>
<div id="matchesSection" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin: 30px 0 15px;">
<h2 style="color: #1e293b;">Review Matches</h2>
<button class="btn-success" id="approveBtn">Apply Approved Matches</button>
</div>
<div class="matches-list" id="matchesList"></div>
</div>
</div>
</div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

1
index.ts Normal file
View file

@ -0,0 +1 @@
console.log("Hello via Bun!");

115
inspect-order.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* Helper script to inspect a single Amazon order page
* Usage: bun inspect-order.ts <order-id>
*/
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<string>();
const dataTestIds = new Set<string>();
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();
}

56
inspect-page.ts Normal file
View file

@ -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<string>();
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<string>();
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');

188
matcher.ts Normal file
View file

@ -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<MatchingOptions> = {}) {
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<string>();
// 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,
};
}
}

2
mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
bun = "1.3.1"

17
package.json Normal file
View file

@ -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"
}
}

291
server.ts Normal file
View file

@ -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<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}`);

29
tsconfig.json Normal file
View file

@ -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
}
}

55
types.ts Normal file
View file

@ -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<T> {
success: boolean;
data?: T;
error?: string;
}

207
ynab-client.ts Normal file
View file

@ -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<YNABTransaction[]> {
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<YNABTransaction[]> {
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<YNABCategory[]> {
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<void> {
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<string> {
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<string | null> {
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);
}
}