vibe coding
This commit is contained in:
commit
f93bc87042
20 changed files with 3043 additions and 0 deletions
10
.env.example
Normal file
10
.env.example
Normal 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
39
.gitignore
vendored
Normal 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
106
CLAUDE.md
Normal 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
207
README.md
Normal 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
106
TROUBLESHOOTING.md
Normal 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
424
amazon-scraper.ts
Normal 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
240
bun.lock
Normal 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
220
category-suggester.ts
Normal 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
332
frontend.tsx
Normal 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
398
index.html
Normal 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
1
index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Hello via Bun!");
|
||||
115
inspect-order.ts
Normal file
115
inspect-order.ts
Normal 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
56
inspect-page.ts
Normal 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
188
matcher.ts
Normal 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
2
mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[tools]
|
||||
bun = "1.3.1"
|
||||
17
package.json
Normal file
17
package.json
Normal 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
291
server.ts
Normal 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
29
tsconfig.json
Normal 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
55
types.ts
Normal 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
207
ynab-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue