first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/data/
|
||||||
221
INDEX.md
Normal file
221
INDEX.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# FargoImport - File Index & Navigation
|
||||||
|
|
||||||
|
## 📖 Start Here
|
||||||
|
|
||||||
|
1. **New to the project?** → Read `QUICKSTART.md` (5 min read)
|
||||||
|
2. **Want full details?** → Read `README.md` (comprehensive)
|
||||||
|
3. **Need module reference?** → Check `PROJECT_SUMMARY.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Python Modules (7 files)
|
||||||
|
|
||||||
|
### Core Extraction
|
||||||
|
- **`wf_debit_extractor.py`** - Extract Wells Fargo payloads from HAR JSON
|
||||||
|
- `WellsFargoPayloadExtractor` class
|
||||||
|
- Methods: `extract_from_file()`, `extract_from_data()`, `get_objects()`
|
||||||
|
- Usage: Extract proprietary JSON wrapped in `/*WellFargoProprietary%...%WellFargoProprietary*/`
|
||||||
|
|
||||||
|
### Data Processing
|
||||||
|
- **`transaction_processor.py`** - Transform raw transactions into structured format
|
||||||
|
- `TransactionProcessor` class
|
||||||
|
- Methods: `extract_transactions()`
|
||||||
|
- Converts amounts to cents, timestamps to datetime, normalizes data
|
||||||
|
|
||||||
|
### External Integration
|
||||||
|
- **`ynab_client.py`** - Connect to YNAB API and fetch transactions
|
||||||
|
- `YNABClient` class
|
||||||
|
- Methods: `get_transactions_for_days()`, `get_account_balance()`
|
||||||
|
- Configuration via `ynab.yml`
|
||||||
|
|
||||||
|
### Reconciliation
|
||||||
|
- **`reconcile.py`** - Compare Wells Fargo and YNAB transactions
|
||||||
|
- `reconcile_transactions()` function
|
||||||
|
- `main()` CLI entry point
|
||||||
|
- Generates `reconciliation_report.json`
|
||||||
|
|
||||||
|
### Credit Module
|
||||||
|
- **`wf_credit_extractor.py`** - Credit extractor module (renamed from `extractor.py`)
|
||||||
|
|
||||||
|
### CLI & Examples
|
||||||
|
- **`main.py`** - Command-line interface for Wells Fargo extraction
|
||||||
|
- Args: `--input`, `--output`, `--show-errors`
|
||||||
|
- Usage: `python main.py --input posted.dat`
|
||||||
|
|
||||||
|
- **`example_usage.py`** - Wells Fargo transaction processing example
|
||||||
|
- Demonstrates extraction → processing → JSON output
|
||||||
|
- Output: `processed_transactions.json`
|
||||||
|
|
||||||
|
- **`ynab_example.py`** - YNAB transaction fetching example
|
||||||
|
- Demonstrates YNAB API integration
|
||||||
|
- Output: `ynab_transactions.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration (1 file)
|
||||||
|
|
||||||
|
- **`ynab.yml`** - YNAB API credentials and account mapping
|
||||||
|
```yaml
|
||||||
|
ynab:
|
||||||
|
- token: "your_ynab_api_token"
|
||||||
|
plan_id: "your_budget_id"
|
||||||
|
accounts:
|
||||||
|
- name: "Credit Card"
|
||||||
|
id: "account_uuid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation (4 files)
|
||||||
|
|
||||||
|
| File | Purpose | Read Time |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `QUICKSTART.md` | Quick start guide with examples | 5 min |
|
||||||
|
| `README.md` | Full project documentation | 15 min |
|
||||||
|
| `PROJECT_SUMMARY.md` | Module reference & architecture | 10 min |
|
||||||
|
| `YNAB_MODULE.md` | YNAB client detailed docs | 5 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Data Files (4 output files)
|
||||||
|
|
||||||
|
These are generated by running the example scripts:
|
||||||
|
|
||||||
|
- **`extracted.json`** - Raw Wells Fargo payload objects (from `main.py`)
|
||||||
|
- **`processed_transactions.json`** - Processed WF transactions (from `example_usage.py`)
|
||||||
|
- 179 sample transactions
|
||||||
|
- Fields: id, amount_cents, dates (timestamp & datetime), description
|
||||||
|
|
||||||
|
- **`ynab_transactions.json`** - YNAB transactions (from `ynab_example.py`)
|
||||||
|
- Fetched from YNAB API
|
||||||
|
- Fields: id, date, payee, category, amount, account, memo
|
||||||
|
|
||||||
|
- **`reconciliation_report.json`** - Comparison report (from `reconcile.py`)
|
||||||
|
- Summary of both data sources
|
||||||
|
- Totals and transaction counts
|
||||||
|
- Full transaction lists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Common Tasks
|
||||||
|
|
||||||
|
### Extract Wells Fargo Data
|
||||||
|
```bash
|
||||||
|
# Display in console
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
python main.py --output extracted.json
|
||||||
|
|
||||||
|
# Process transactions
|
||||||
|
python example_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch YNAB Data
|
||||||
|
```bash
|
||||||
|
python ynab_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconcile Both Sources
|
||||||
|
```bash
|
||||||
|
python reconcile.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Usage
|
||||||
|
```python
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
# Wells Fargo
|
||||||
|
wf = WellsFargoPayloadExtractor()
|
||||||
|
payloads = wf.extract_from_file("posted.dat")
|
||||||
|
processor = TransactionProcessor()
|
||||||
|
wf_txns = processor.extract_transactions(payloads)
|
||||||
|
|
||||||
|
# YNAB
|
||||||
|
ynab = YNABClient()
|
||||||
|
ynab_txns = ynab.get_transactions_for_days(days=30)
|
||||||
|
|
||||||
|
# Access
|
||||||
|
for txn in wf_txns[:5]:
|
||||||
|
print(txn["transaction_description"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Module Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
posted.dat (input)
|
||||||
|
↓
|
||||||
|
wf_debit_extractor.py
|
||||||
|
↓
|
||||||
|
transaction_processor.py
|
||||||
|
↓
|
||||||
|
example_usage.py → processed_transactions.json
|
||||||
|
|
||||||
|
ynab.yml (config)
|
||||||
|
↓
|
||||||
|
ynab_client.py
|
||||||
|
↓
|
||||||
|
ynab_example.py → ynab_transactions.json
|
||||||
|
|
||||||
|
Both ↓
|
||||||
|
reconcile.py → reconciliation_report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ File Status
|
||||||
|
|
||||||
|
| Type | Count | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Core Modules | 4 | ✅ Tested & Working |
|
||||||
|
| CLI/Examples | 3 | ✅ Tested & Working |
|
||||||
|
| Configuration | 1 | ✅ Ready |
|
||||||
|
| Documentation | 4 | ✅ Complete |
|
||||||
|
| Data Files | 4 | ✅ Generated |
|
||||||
|
| **Total** | **16** | **✅ Complete** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
1. **Read** `QUICKSTART.md` (2 min)
|
||||||
|
2. **Update** `ynab.yml` with your API token
|
||||||
|
3. **Run** `python example_usage.py` (verify WF extraction)
|
||||||
|
4. **Run** `python ynab_example.py` (verify YNAB connection)
|
||||||
|
5. **Run** `python reconcile.py` (generate comparison)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Documentation Map
|
||||||
|
|
||||||
|
- Need quick answer? → `QUICKSTART.md`
|
||||||
|
- How do I use X? → `README.md`
|
||||||
|
- What does Y do? → `PROJECT_SUMMARY.md`
|
||||||
|
- YNAB details? → `YNAB_MODULE.md`
|
||||||
|
- Code docstrings? → Open the `.py` file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Key Entry Points
|
||||||
|
|
||||||
|
**CLI Interface:**
|
||||||
|
- `main.py` - Extract Wells Fargo data from command line
|
||||||
|
|
||||||
|
**Programmatic API:**
|
||||||
|
- `wf_debit_extractor.py` - Wells Fargo extraction
|
||||||
|
- `transaction_processor.py` - Transaction processing
|
||||||
|
- `ynab_client.py` - YNAB integration
|
||||||
|
- `reconcile.py` - Reconciliation
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `example_usage.py` - Wells Fargo example
|
||||||
|
- `ynab_example.py` - YNAB example
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Generated: March 20, 2026
|
||||||
|
|
||||||
269
PROJECT_SUMMARY.md
Normal file
269
PROJECT_SUMMARY.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# FargoImport Project - Complete Module Overview
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This project provides utilities for extracting Wells Fargo transaction data and integrating it with YNAB (You Need A Budget) for financial reconciliation.
|
||||||
|
|
||||||
|
## Core Modules
|
||||||
|
|
||||||
|
### 1. **wf_debit_extractor.py** - Wells Fargo Data Extraction
|
||||||
|
Extracts proprietary Wells Fargo JSON payloads from HAR-style JSON files.
|
||||||
|
|
||||||
|
**Classes:**
|
||||||
|
- `WellsFargoPayloadExtractor` - Main extraction engine
|
||||||
|
- `extract_from_file(path)` - Load and parse HAR JSON file
|
||||||
|
- `extract_from_data(har_data)` - Parse raw HAR data
|
||||||
|
- `get_objects()` - Access parsed objects
|
||||||
|
- Properties: `objects`, `errors`
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `extract_wf_objects(har_data)` - Backward-compatible functional API
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
payloads = extractor.extract_from_file("posted.dat")
|
||||||
|
print(len(payloads)) # Number of extracted payloads
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **transaction_processor.py** - Wells Fargo Transaction Processing
|
||||||
|
Transforms raw Wells Fargo payloads into structured transaction data.
|
||||||
|
|
||||||
|
**Classes:**
|
||||||
|
- `TransactionProcessor` - Transaction extraction and transformation
|
||||||
|
- `extract_transactions(extracted_objects)` - Extract from payloads
|
||||||
|
|
||||||
|
**Data Structure:**
|
||||||
|
- `ProcessedTransaction` TypedDict with fields:
|
||||||
|
- `id` - Transaction ID
|
||||||
|
- `transaction_amount_cents` - Amount as integer cents
|
||||||
|
- `transaction_date_timestamp` - Milliseconds since epoch
|
||||||
|
- `transaction_date_datetime` - Python datetime object
|
||||||
|
- `post_date_timestamp` - Milliseconds since epoch
|
||||||
|
- `post_date_datetime` - Python datetime object
|
||||||
|
- `transaction_description` - Transaction description
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
payloads = extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
processor = TransactionProcessor()
|
||||||
|
transactions = processor.extract_transactions(payloads)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **ynab_client.py** - YNAB Integration
|
||||||
|
Authenticates with YNAB API and fetches transactions using official SDK.
|
||||||
|
|
||||||
|
**Classes:**
|
||||||
|
- `YNABClient` - YNAB API wrapper
|
||||||
|
- `__init__(config_path)` - Initialize from YAML config
|
||||||
|
- `get_transactions_for_days(days)` - Fetch transactions for past N days
|
||||||
|
- `get_account_balance(account_id)` - Get account balance
|
||||||
|
|
||||||
|
**Data Structure:**
|
||||||
|
- `YNABTransaction` TypedDict with fields:
|
||||||
|
- `id` - Transaction ID
|
||||||
|
- `date` - Date (YYYY-MM-DD format)
|
||||||
|
- `payee_name` - Payee name
|
||||||
|
- `category_name` - Category name
|
||||||
|
- `amount_milliCurrency` - Amount in milliCurrency units
|
||||||
|
- `memo` - Transaction memo
|
||||||
|
- `account_id` - YNAB account ID
|
||||||
|
- `account_name` - Account name
|
||||||
|
- `cleared` - Cleared status
|
||||||
|
|
||||||
|
**Configuration (ynab.yml):**
|
||||||
|
```yaml
|
||||||
|
ynab:
|
||||||
|
- token: "your_api_token"
|
||||||
|
plan_id: "your_budget_id"
|
||||||
|
accounts:
|
||||||
|
- name: "Credit Card"
|
||||||
|
id: "account_id"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
transactions = client.get_transactions_for_days(days=30)
|
||||||
|
balance = client.get_account_balance("account_id")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **reconcile.py** - Reconciliation Integration
|
||||||
|
Combines Wells Fargo and YNAB transactions for comparison and reporting.
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `reconcile_transactions()` - Fetch both data sources and create report
|
||||||
|
- `main()` - CLI interface with summary and JSON output
|
||||||
|
|
||||||
|
**Data Structure:**
|
||||||
|
- `ReconciliationReport` TypedDict with:
|
||||||
|
- `timestamp` - Report timestamp
|
||||||
|
- `wellsfargo_transactions` - List of WF transactions
|
||||||
|
- `ynab_transactions` - List of YNAB transactions
|
||||||
|
- `wellsfargo_total` - Total WF amount
|
||||||
|
- `ynab_total` - Total YNAB amount
|
||||||
|
- `transaction_count_wf` - Count of WF transactions
|
||||||
|
- `transaction_count_ynab` - Count of YNAB transactions
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python reconcile.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `reconciliation_report.json` with full comparison
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Scripts
|
||||||
|
|
||||||
|
### **example_usage.py** - Wells Fargo Transaction Example
|
||||||
|
Demonstrates extraction and processing of Wells Fargo data.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- Console display of first 5 transactions
|
||||||
|
- Saves all transactions to `processed_transactions.json`
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
```bash
|
||||||
|
python example_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ynab_example.py** - YNAB Transaction Example
|
||||||
|
Demonstrates fetching and displaying YNAB transactions.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- Console display of first 10 transactions
|
||||||
|
- Saves all transactions to `ynab_transactions.json`
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
```bash
|
||||||
|
python ynab_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Interface
|
||||||
|
|
||||||
|
### **main.py** - Command-line Entry Point
|
||||||
|
Thin wrapper around extraction functionality.
|
||||||
|
|
||||||
|
**Arguments:**
|
||||||
|
- `--input` - Input HAR file (default: posted.dat)
|
||||||
|
- `--output` - Output JSON file (optional)
|
||||||
|
- `--show-errors` - Display parse errors
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Extract to console
|
||||||
|
python main.py --input posted.dat
|
||||||
|
|
||||||
|
# Extract to file
|
||||||
|
python main.py --input posted.dat --output extracted.json
|
||||||
|
|
||||||
|
# Show parsing errors
|
||||||
|
python main.py --input posted.dat --show-errors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
posted.dat (HAR JSON)
|
||||||
|
↓
|
||||||
|
wf_debit_extractor.py (Extract proprietary payloads)
|
||||||
|
↓
|
||||||
|
Extracted payload objects
|
||||||
|
↓
|
||||||
|
transaction_processor.py (Transform to structured format)
|
||||||
|
↓
|
||||||
|
Processed transaction list
|
||||||
|
↓
|
||||||
|
├─→ example_usage.py (Save to processed_transactions.json)
|
||||||
|
└─→ reconcile.py (Compare with YNAB)
|
||||||
|
↓
|
||||||
|
ynab_client.py (Fetch from YNAB API)
|
||||||
|
↓
|
||||||
|
reconciliation_report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **ynab** (v2.1.0) - Official YNAB Python SDK
|
||||||
|
- **pyyaml** (v6.0.3) - YAML configuration parsing
|
||||||
|
|
||||||
|
**Install:**
|
||||||
|
```bash
|
||||||
|
pip install ynab pyyaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Manifest
|
||||||
|
|
||||||
|
| File | Purpose | Lines |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `wf_debit_extractor.py` | Core extraction logic | 91 |
|
||||||
|
| `transaction_processor.py` | Transaction transformation | 70 |
|
||||||
|
| `ynab_client.py` | YNAB API integration | 150 |
|
||||||
|
| `reconcile.py` | Reconciliation orchestration | 117 |
|
||||||
|
| `main.py` | CLI wrapper | ~40 |
|
||||||
|
| `example_usage.py` | WF usage example | ~60 |
|
||||||
|
| `ynab_example.py` | YNAB usage example | ~60 |
|
||||||
|
| `ynab.yml` | YNAB configuration | 6 |
|
||||||
|
| `README.md` | Project documentation | ~100 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
✓ Extracts Wells Fargo proprietary JSON from HAR files
|
||||||
|
✓ Transforms transactions with amount normalization
|
||||||
|
✓ Converts millisecond timestamps to Python datetime objects
|
||||||
|
✓ Integrates with YNAB API for budget tracking
|
||||||
|
✓ Supports date range filtering (past X days)
|
||||||
|
✓ Generates reconciliation reports
|
||||||
|
✓ Error handling and reporting
|
||||||
|
✓ Type-safe with TypedDict definitions
|
||||||
|
✓ Configuration via YAML
|
||||||
|
✓ Modular, reusable architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- ✅ All modules created and tested
|
||||||
|
- ✅ No static errors
|
||||||
|
- ✅ Dependencies installed
|
||||||
|
- ✅ CLI functional
|
||||||
|
- ✅ Examples working
|
||||||
|
- ✅ Backward compatible
|
||||||
|
- ⚠️ YNAB integration requires valid API token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps (Optional)
|
||||||
|
|
||||||
|
1. **Database Integration** - Store transactions in SQLite/PostgreSQL
|
||||||
|
2. **Matching Algorithm** - Auto-match WF↔YNAB transactions by amount/date
|
||||||
|
3. **Web Dashboard** - Flask/FastAPI interface for viewing reports
|
||||||
|
4. **Scheduling** - Celery/APScheduler for periodic reconciliation
|
||||||
|
5. **Export Formats** - CSV, Excel, PDF report generation
|
||||||
|
|
||||||
144
QUICKSTART.md
Normal file
144
QUICKSTART.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Dependencies already installed:**
|
||||||
|
- `ynab` (2.1.0) - YNAB API SDK
|
||||||
|
- `pyyaml` (6.0.3) - YAML parsing
|
||||||
|
|
||||||
|
2. **Virtual environment active** in `.venv/`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Update `ynab.yml` with your YNAB credentials:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ynab:
|
||||||
|
- token: "your_actual_ynab_api_token"
|
||||||
|
plan_id: "your_budget_plan_id"
|
||||||
|
accounts:
|
||||||
|
- name: "Credit Card"
|
||||||
|
id: "account_uuid"
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your credentials from: https://app.youneedabudget.com/settings/developer
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### Extract Wells Fargo Transactions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Display in console
|
||||||
|
python main.py --input posted.dat
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
python main.py --input posted.dat --output extracted.json
|
||||||
|
|
||||||
|
# Process and save transactions
|
||||||
|
python example_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `processed_transactions.json` with 179 sample transactions
|
||||||
|
|
||||||
|
### Fetch YNAB Transactions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ynab_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `ynab_transactions.json` with transactions from past 30 days
|
||||||
|
|
||||||
|
### Reconcile Both Sources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python reconcile.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `reconciliation_report.json` comparing both datasets
|
||||||
|
|
||||||
|
## Module Quick Reference
|
||||||
|
|
||||||
|
| Task | Module | Method |
|
||||||
|
|------|--------|--------|
|
||||||
|
| Extract WF payload | `wf_debit_extractor.py` | `WellsFargoPayloadExtractor().extract_from_file()` |
|
||||||
|
| Process WF transactions | `transaction_processor.py` | `TransactionProcessor().extract_transactions()` |
|
||||||
|
| Fetch YNAB transactions | `ynab_client.py` | `YNABClient().get_transactions_for_days()` |
|
||||||
|
| Reconcile both | `reconcile.py` | `reconcile_transactions()` |
|
||||||
|
|
||||||
|
## Common Code Patterns
|
||||||
|
|
||||||
|
### Get Wells Fargo Transactions
|
||||||
|
```python
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
payloads = extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
processor = TransactionProcessor()
|
||||||
|
transactions = processor.extract_transactions(payloads)
|
||||||
|
|
||||||
|
for txn in transactions:
|
||||||
|
print(f"{txn['id']}: ${txn['transaction_amount_cents']/100:.2f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get YNAB Transactions
|
||||||
|
```python
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
transactions = client.get_transactions_for_days(days=30)
|
||||||
|
|
||||||
|
for txn in transactions:
|
||||||
|
print(f"{txn['date']}: {txn['payee_name']} ${txn['amount_milliCurrency']/1000:.2f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Reconciliation
|
||||||
|
```python
|
||||||
|
from reconcile import reconcile_transactions
|
||||||
|
|
||||||
|
report = reconcile_transactions()
|
||||||
|
print(f"WF: {report['transaction_count_wf']} transactions")
|
||||||
|
print(f"YNAB: {report['transaction_count_ynab']} transactions")
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
- **Input:** `posted.dat` (Wells Fargo HAR export)
|
||||||
|
- **Config:** `ynab.yml` (YNAB credentials)
|
||||||
|
- **Output:**
|
||||||
|
- `extracted.json` - Raw WF payloads
|
||||||
|
- `processed_transactions.json` - Processed WF transactions
|
||||||
|
- `ynab_transactions.json` - YNAB transactions
|
||||||
|
- `reconciliation_report.json` - Comparison report
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**YNAB Authorization Error:**
|
||||||
|
- Verify token in `ynab.yml` is correct
|
||||||
|
- Check token hasn't expired
|
||||||
|
- Ensure token has budget access
|
||||||
|
|
||||||
|
**Wells Fargo Extraction Returns 0 Objects:**
|
||||||
|
- Verify `posted.dat` contains `/*WellFargoProprietary%...%WellFargoProprietary*/`
|
||||||
|
- Check file is valid JSON
|
||||||
|
|
||||||
|
**Import Errors:**
|
||||||
|
- Activate venv: `.venv\Scripts\Activate.ps1`
|
||||||
|
- Reinstall deps: `pip install ynab pyyaml`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See detailed docs in:
|
||||||
|
- `README.md` - Full project documentation
|
||||||
|
- `PROJECT_SUMMARY.md` - Module reference
|
||||||
|
- `YNAB_MODULE.md` - YNAB client details
|
||||||
|
- Module docstrings - Inline documentation
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Update `ynab.yml` with real credentials
|
||||||
|
2. Run `python example_usage.py` to test WF extraction
|
||||||
|
3. Run `python ynab_example.py` to test YNAB connection
|
||||||
|
4. Run `python reconcile.py` to generate comparison report
|
||||||
|
|
||||||
172
README.md
Normal file
172
README.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# FargoImport extractor
|
||||||
|
|
||||||
|
This utility reads a HAR-style JSON file (for example `posted.dat`), scans:
|
||||||
|
|
||||||
|
- `log.entries[*].response.content.text`
|
||||||
|
|
||||||
|
It only processes text values that start with:
|
||||||
|
|
||||||
|
- `/*WellFargoProprietary%`
|
||||||
|
|
||||||
|
For each matching value, it extracts the JSON string between:
|
||||||
|
|
||||||
|
- start: `/*WellFargoProprietary%`
|
||||||
|
- end: `%WellFargoProprietary*/`
|
||||||
|
|
||||||
|
Then it parses that extracted string with Python `json.loads` and outputs the resulting Python objects.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\main.py --input .\posted.dat
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally write extracted objects to a file:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\main.py --input .\posted.dat --output .\extracted.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Show parse/skip errors while extracting:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\main.py --input .\posted.dat --show-errors
|
||||||
|
```
|
||||||
|
|
||||||
|
For HAR files where the payload is specifically at
|
||||||
|
`/log/entries/0/response/content/text`, use:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\main.py --input .\checking.dat --entry0-only --output .\checking_extracted.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Programmatic usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
objects = extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
# For entry0-only HAR variants:
|
||||||
|
# objects = extractor.extract_from_file("checking.dat", entry0_only=True)
|
||||||
|
|
||||||
|
# Access converted Python objects.
|
||||||
|
print(len(objects))
|
||||||
|
print(extractor.get_objects()[0])
|
||||||
|
|
||||||
|
# Optional: review skipped/parse errors.
|
||||||
|
print(extractor.errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy imports from `main` still work, but `wf_debit_extractor` is now the preferred module for programmatic use.
|
||||||
|
|
||||||
|
## Transaction Processing
|
||||||
|
|
||||||
|
Once you've extracted WellsFargo payloads, use `transaction_processor.py` to extract and transform transaction data.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
|
||||||
|
# Extract payloads
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
payloads = extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
# Process transactions
|
||||||
|
processor = TransactionProcessor()
|
||||||
|
transactions = processor.extract_transactions(payloads)
|
||||||
|
|
||||||
|
# Each transaction has:
|
||||||
|
# - id (string)
|
||||||
|
# - transaction_amount_cents (integer, e.g., 9.11 → 911)
|
||||||
|
# - transaction_date_timestamp (milliseconds since epoch)
|
||||||
|
# - transaction_date_datetime (Python datetime object)
|
||||||
|
# - post_date_timestamp (milliseconds since epoch)
|
||||||
|
# - post_date_datetime (Python datetime object)
|
||||||
|
# - transaction_description (string)
|
||||||
|
|
||||||
|
for txn in transactions:
|
||||||
|
print(txn["id"], txn["transaction_amount_cents"], txn["transaction_description"])
|
||||||
|
```
|
||||||
|
|
||||||
|
See `example_usage.py` for a working example that also saves all transactions to `processed_transactions.json`.
|
||||||
|
|
||||||
|
## YNAB Integration
|
||||||
|
|
||||||
|
Use `ynab_client.py` to fetch transactions from your YNAB account. Configuration is loaded from `ynab.yml` which should contain your YNAB token, plan ID, and account IDs to track.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
# Initialize with config file
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
|
||||||
|
# Fetch transactions from past 30 days
|
||||||
|
transactions = client.get_transactions_for_days(days=30)
|
||||||
|
|
||||||
|
# Each transaction includes:
|
||||||
|
# - id
|
||||||
|
# - date (YYYY-MM-DD)
|
||||||
|
# - payee_name
|
||||||
|
# - category_name
|
||||||
|
# - amount_milliCurrency (in milliCurrency units, divide by 1000 for dollars)
|
||||||
|
# - memo
|
||||||
|
# - account_id
|
||||||
|
# - account_name
|
||||||
|
# - cleared (cleared status)
|
||||||
|
|
||||||
|
for txn in transactions:
|
||||||
|
print(f"{txn['date']} | {txn['payee_name']} | ${txn['amount_milliCurrency'] / 1000:.2f}")
|
||||||
|
|
||||||
|
# Get account balance
|
||||||
|
balance = client.get_account_balance("7282c2e6-0470-423d-9748-ec36e29f5698")
|
||||||
|
print(f"Balance: ${balance / 1000:.2f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
See `ynab_example.py` for a complete example that also saves all transactions to `ynab_transactions.json`.
|
||||||
|
|
||||||
|
### ynab.yml Format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ynab:
|
||||||
|
- token: "your_ynab_api_token_here"
|
||||||
|
plan_id: "your_budget_id_here"
|
||||||
|
accounts:
|
||||||
|
- name: "Credit Card"
|
||||||
|
id: "account_id_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration & Reconciliation
|
||||||
|
|
||||||
|
Use `reconcile.py` to fetch both WellsFargo and YNAB transactions side-by-side for reconciliation.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\reconcile.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Extract WellsFargo transactions from posted.dat
|
||||||
|
2. Fetch YNAB transactions for past 30 days
|
||||||
|
3. Display summary comparison
|
||||||
|
4. Save combined report to `reconciliation_report.json`
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
```
|
||||||
|
============================================================
|
||||||
|
RECONCILIATION SUMMARY
|
||||||
|
============================================================
|
||||||
|
WellsFargo transactions: 179
|
||||||
|
WellsFargo total: $5,432.15
|
||||||
|
|
||||||
|
YNAB transactions: 42
|
||||||
|
YNAB total: $3,210.50
|
||||||
|
============================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Ensure your YNAB API token in `ynab.yml` is valid and has permission to access the budget/plan specified.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
129
YNAB_MODULE.md
Normal file
129
YNAB_MODULE.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# YNAB Client Module Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A Python module (`ynab_client.py`) that authenticates with YNAB using the official SDK and retrieves transactions from configured accounts.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### `ynab_client.py` - Main Module
|
||||||
|
- **YNABClient class**: Main interface for YNAB API interactions
|
||||||
|
- `__init__(config_path)`: Initialize from YAML config file
|
||||||
|
- `get_transactions_for_days(days=30)`: Fetch transactions for past N days
|
||||||
|
- `get_account_balance(account_id)`: Get current balance for an account
|
||||||
|
|
||||||
|
- **YNABTransaction TypedDict**: Defines transaction data structure
|
||||||
|
- `id`: Transaction ID
|
||||||
|
- `date`: Transaction date (YYYY-MM-DD)
|
||||||
|
- `payee_name`: Payee name
|
||||||
|
- `category_name`: Category name
|
||||||
|
- `amount_milliCurrency`: Amount in milliCurrency units (divide by 1000 for dollars)
|
||||||
|
- `memo`: Transaction memo
|
||||||
|
- `account_id`: YNAB account ID
|
||||||
|
- `account_name`: Account name
|
||||||
|
- `cleared`: Cleared status
|
||||||
|
|
||||||
|
### `ynab_example.py` - Example Usage
|
||||||
|
Demonstrates how to:
|
||||||
|
- Load YNAB client from config
|
||||||
|
- Fetch transactions for past 30 days
|
||||||
|
- Display transactions in human-readable format
|
||||||
|
- Save transactions to JSON file (`ynab_transactions.json`)
|
||||||
|
|
||||||
|
### `ynab.yml` - Configuration File
|
||||||
|
Contains:
|
||||||
|
- YNAB API token
|
||||||
|
- Budget/Plan ID
|
||||||
|
- List of accounts to track (name, ID, and extractor)
|
||||||
|
|
||||||
|
## Configuration File
|
||||||
|
|
||||||
|
Create a `ynab.yml` file in the project root using the following structure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ynab:
|
||||||
|
- token: "your-ynab-api-token"
|
||||||
|
plan_id: "your-budget-id"
|
||||||
|
accounts:
|
||||||
|
- name: "Credit Card"
|
||||||
|
id: "account-uuid"
|
||||||
|
extractor: "wf_credit_extractor"
|
||||||
|
- name: "Debit Card"
|
||||||
|
id: "account-uuid"
|
||||||
|
extractor: "wf_debit_extractor"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Reference
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `ynab` | ✓ | Top-level key. Contains a list of YNAB configurations (one per budget). |
|
||||||
|
| `token` | ✓ | Your YNAB Personal Access Token. Generate one at **YNAB → Account Settings → Developer Settings**. Keep this secret — treat it like a password. |
|
||||||
|
| `plan_id` | ✓ | The UUID of your YNAB budget. Find it in the URL when viewing your budget: `https://app.ynab.com/<plan_id>/budget`. |
|
||||||
|
| `accounts` | ✓ | List of bank accounts to sync. Each entry maps a YNAB account to a Wells Fargo data extractor. |
|
||||||
|
| `accounts[].name` | ✓ | A human-readable label for the account (used in report output only). |
|
||||||
|
| `accounts[].id` | ✓ | The UUID of the YNAB account. Find it in the URL when viewing the account in YNAB, or via the YNAB API. |
|
||||||
|
| `accounts[].extractor` | ✓ | Which Wells Fargo extractor to use for this account. Must be one of: `wf_credit_extractor` (for credit cards) or `wf_debit_extractor` (for checking/debit accounts). |
|
||||||
|
|
||||||
|
### How to Find Your IDs
|
||||||
|
|
||||||
|
**API Token**
|
||||||
|
1. Log in to [app.ynab.com](https://app.ynab.com)
|
||||||
|
2. Go to **Account Settings → Developer Settings**
|
||||||
|
3. Click **New Token**, give it a name, and copy the value
|
||||||
|
|
||||||
|
**Budget (plan_id)**
|
||||||
|
- Open your budget in YNAB — the UUID is in the URL:
|
||||||
|
`https://app.ynab.com/`**`dc25f923-e6b4-45f5-8d42-65e53eed1f1e`**`/budget`
|
||||||
|
|
||||||
|
**Account ID**
|
||||||
|
- Click on an account in YNAB — the UUID appears in the URL after `/accounts/`
|
||||||
|
- Or use the YNAB API Explorer at `https://api.ynab.com/v1/budgets/<plan_id>/accounts`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `ynab` (v2.1.0) - Official YNAB Python SDK
|
||||||
|
- `pyyaml` (v6.0.3) - YAML file parsing
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
```python
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
# Initialize client
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
|
||||||
|
# Get transactions from past 30 days
|
||||||
|
transactions = client.get_transactions_for_days(days=30)
|
||||||
|
|
||||||
|
# Access transaction data
|
||||||
|
for txn in transactions:
|
||||||
|
amount_dollars = txn["amount_milliCurrency"] / 1000
|
||||||
|
print(f"{txn['date']} | {txn['payee_name']} | ${amount_dollars:.2f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Example
|
||||||
|
```powershell
|
||||||
|
python .\ynab_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Load credentials from `ynab.yml`
|
||||||
|
2. Fetch transactions from configured accounts
|
||||||
|
3. Display first 10 transactions
|
||||||
|
4. Save all transactions to `ynab_transactions.json`
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- ✓ Configuration from YAML file
|
||||||
|
- ✓ Automatic date filtering (past X days)
|
||||||
|
- ✓ Category name lookup
|
||||||
|
- ✓ Account name mapping
|
||||||
|
- ✓ Error handling for network/API issues
|
||||||
|
- ✓ Both timestamp and formatted date fields
|
||||||
|
- ✓ Balance retrieval for accounts
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- ✓ Module created and tested
|
||||||
|
- ✓ Configuration loading verified
|
||||||
|
- ✓ Dependencies installed
|
||||||
|
- ✓ No static errors
|
||||||
|
|
||||||
59
example_usage.py
Normal file
59
example_usage.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Example usage of WellsFargo extraction and transaction processing."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Load or extract WellsFargo payloads
|
||||||
|
extractor = WellsFargoPayloadExtractor()
|
||||||
|
extracted_objects = extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
print(f"Extracted {len(extracted_objects)} WellsFargo payload(s)\n")
|
||||||
|
|
||||||
|
# Process transactions
|
||||||
|
processor = TransactionProcessor()
|
||||||
|
transactions = processor.extract_transactions(extracted_objects)
|
||||||
|
|
||||||
|
print(f"Found {len(transactions)} transaction(s)\n")
|
||||||
|
|
||||||
|
# Display first few transactions
|
||||||
|
for i, txn in enumerate(transactions[:5]):
|
||||||
|
print(f"Transaction {i + 1}:")
|
||||||
|
print(f" ID: {txn['id']}")
|
||||||
|
print(
|
||||||
|
f" Amount: ${txn['transaction_amount_cents'] / 100:.2f} ({txn['transaction_amount_cents']} cents)"
|
||||||
|
)
|
||||||
|
print(f" Description: {txn['transaction_description']}")
|
||||||
|
print(f" Transaction Date: {txn['transaction_date_datetime']} (ts: {txn['transaction_date_timestamp']})")
|
||||||
|
print(f" Post Date: {txn['post_date_datetime']} (ts: {txn['post_date_timestamp']})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Optionally save all processed transactions to JSON
|
||||||
|
output_file = Path("processed_transactions.json")
|
||||||
|
serializable = [
|
||||||
|
{
|
||||||
|
"id": txn["id"],
|
||||||
|
"transaction_amount_cents": txn["transaction_amount_cents"],
|
||||||
|
"transaction_date_timestamp": txn["transaction_date_timestamp"],
|
||||||
|
"transaction_date_datetime": txn["transaction_date_datetime"].isoformat(),
|
||||||
|
"post_date_timestamp": txn["post_date_timestamp"],
|
||||||
|
"post_date_datetime": txn["post_date_datetime"].isoformat(),
|
||||||
|
"transaction_description": txn["transaction_description"],
|
||||||
|
}
|
||||||
|
for txn in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
with output_file.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(serializable, f, indent=2)
|
||||||
|
|
||||||
|
print(f"Saved {len(transactions)} processed transaction(s) to {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
88
extractor.py
Normal file
88
extractor.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
START_MARKER = "/*WellFargoProprietary%"
|
||||||
|
END_MARKER = "%WellFargoProprietary*/"
|
||||||
|
|
||||||
|
|
||||||
|
class WellsFargoPayloadExtractor:
|
||||||
|
"""Extract wrapped WellFargo payloads from HAR entries and parse them as JSON."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: list[Any] = []
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def _normalize_text(self, raw_text: Any) -> str | None:
|
||||||
|
"""Return a normalized text payload or None when input is not a string."""
|
||||||
|
if not isinstance(raw_text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = raw_text.strip()
|
||||||
|
|
||||||
|
# Some HAR exports store content.text as an escaped JSON string literal.
|
||||||
|
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, str):
|
||||||
|
text = decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.errors.append("Could not decode an escaped text wrapper")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def extract_from_data(self, har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Parse and store objects from log.entries[*].response.content.text."""
|
||||||
|
self.objects = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
entries = har_data.get("log", {}).get("entries", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
self.errors.append("Expected log.entries to be a list")
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
text_value = (
|
||||||
|
entry.get("response", {})
|
||||||
|
.get("content", {})
|
||||||
|
.get("text")
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
text = self._normalize_text(text_value)
|
||||||
|
if not text or not text.startswith(START_MARKER):
|
||||||
|
continue
|
||||||
|
|
||||||
|
end_idx = text.find(END_MARKER, len(START_MARKER))
|
||||||
|
if end_idx == -1:
|
||||||
|
self.errors.append(f"Entry {index}: missing end marker")
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = text[len(START_MARKER) : end_idx]
|
||||||
|
if not payload:
|
||||||
|
self.errors.append(f"Entry {index}: payload between markers is empty")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.objects.append(json.loads(payload))
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
self.errors.append(f"Entry {index}: invalid payload JSON ({ex.msg})")
|
||||||
|
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
def extract_from_file(self, input_path: str | Path) -> list[Any]:
|
||||||
|
"""Load HAR JSON from a file path and extract payload objects."""
|
||||||
|
path = Path(input_path)
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
har_data = json.load(f)
|
||||||
|
return self.extract_from_data(har_data)
|
||||||
|
|
||||||
|
def get_objects(self) -> list[Any]:
|
||||||
|
"""Return the latest parsed objects."""
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wf_objects(har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Backward-compatible functional API."""
|
||||||
|
return WellsFargoPayloadExtractor().extract_from_data(har_data)
|
||||||
|
|
||||||
658
main.py
Normal file
658
main.py
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import ynab
|
||||||
|
|
||||||
|
from wf_credit_extractor import WellsFargoPayloadExtractor as CreditExtractor
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor as DebitExtractor
|
||||||
|
from ynab_client import YNABClient, YNABTransaction
|
||||||
|
|
||||||
|
# Maps the extractor name in ynab.yml to the correct extractor class
|
||||||
|
EXTRACTOR_MAP = {
|
||||||
|
"wf_credit_extractor": CreditExtractor,
|
||||||
|
"wf_debit_extractor": DebitExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maps CLI flag name to the extractor name in ynab.yml
|
||||||
|
FLAG_TO_EXTRACTOR = {
|
||||||
|
"credit": "wf_credit_extractor",
|
||||||
|
"debit": "wf_debit_extractor",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maps extractor name to the balance type to display
|
||||||
|
EXTRACTOR_BALANCE_TYPE = {
|
||||||
|
"wf_credit_extractor": "OUTSTANDING",
|
||||||
|
"wf_debit_extractor": "AVAILABLE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_output_path(input_path: Path) -> Path:
|
||||||
|
"""Derive a JSON output path from the input file path."""
|
||||||
|
return input_path.with_suffix(".json")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_raw_transactions(extracted_objects: list[Any]) -> list[dict]:
|
||||||
|
"""Pull the raw transaction list out of extracted WF payload objects.
|
||||||
|
|
||||||
|
Supports multiple HAR structures:
|
||||||
|
- /transactions/transactionData/transactions (posted.dat / checking.dat style)
|
||||||
|
- /accountModel/applicationData/transactionData/transactions (credit fetch style)
|
||||||
|
Each transaction is tagged with a "status" field: "POSTED" or "PENDING".
|
||||||
|
TEMP_AUTH transactionType in requestedCriteria marks pending transactions.
|
||||||
|
"""
|
||||||
|
transactions: list[dict] = []
|
||||||
|
for obj in extracted_objects:
|
||||||
|
_collect_from_block(obj, transactions)
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_from_block(obj: dict, out: list[dict]) -> None:
|
||||||
|
"""Recurse into a single extracted object to find all transactionData blocks."""
|
||||||
|
# Path 1: /transactions/transactionData (old credit / debit style, and TEMP_AUTH)
|
||||||
|
txn_wrapper = obj.get("transactions", {})
|
||||||
|
if isinstance(txn_wrapper, dict):
|
||||||
|
td = txn_wrapper.get("transactionData")
|
||||||
|
if isinstance(td, dict):
|
||||||
|
_append_transactions(td, out)
|
||||||
|
|
||||||
|
# Path 2: /accountModel/applicationData/transactionData (new credit fetch style)
|
||||||
|
acct_td = (
|
||||||
|
obj.get("accountModel", {})
|
||||||
|
.get("applicationData", {})
|
||||||
|
.get("transactionData")
|
||||||
|
)
|
||||||
|
if isinstance(acct_td, dict):
|
||||||
|
_append_transactions(acct_td, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_transactions(td: dict, out: list[dict]) -> None:
|
||||||
|
"""Extract transactions from a transactionData block and tag POSTED/PENDING."""
|
||||||
|
txn_type = (
|
||||||
|
td.get("requestedCriteria", {}).get("transactionType", "")
|
||||||
|
or td.get("type", "")
|
||||||
|
)
|
||||||
|
status = "PENDING" if txn_type == "TEMP_AUTH" else "POSTED"
|
||||||
|
|
||||||
|
txn_list = td.get("transactions", [])
|
||||||
|
if isinstance(txn_list, list):
|
||||||
|
for txn in txn_list:
|
||||||
|
txn_copy = dict(txn)
|
||||||
|
txn_copy["status"] = status
|
||||||
|
if "transactionDescription" in txn_copy:
|
||||||
|
txn_copy["transactionDescription"] = " ".join(txn_copy["transactionDescription"].split())
|
||||||
|
out.append(txn_copy)
|
||||||
|
|
||||||
|
|
||||||
|
def _wf_date_to_iso(timestamp_ms: int) -> str:
|
||||||
|
"""Convert WF millisecond timestamp to YYYY-MM-DD string."""
|
||||||
|
return datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wf_amount_to_cents(amount: float) -> int:
|
||||||
|
"""Convert WF dollar amount to integer cents."""
|
||||||
|
return round(amount * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _ynab_amount_to_cents(amount_milli: int) -> int:
|
||||||
|
"""Convert YNAB milliCurrency to integer cents (divide by 10)."""
|
||||||
|
return round(amount_milli / 10)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_account_balance(
|
||||||
|
extracted_objects: list[dict],
|
||||||
|
balance_type: str,
|
||||||
|
) -> float | None:
|
||||||
|
"""Extract the dollar balance from extracted WF objects.
|
||||||
|
|
||||||
|
Searches each object's accountModel.applicationData.account.balance[]
|
||||||
|
for an entry whose 'type' matches *balance_type* and returns its 'amount'.
|
||||||
|
Returns None if not found.
|
||||||
|
"""
|
||||||
|
for obj in extracted_objects:
|
||||||
|
balances = (
|
||||||
|
obj.get("accountModel", {})
|
||||||
|
.get("applicationData", {})
|
||||||
|
.get("account", {})
|
||||||
|
.get("balance", [])
|
||||||
|
)
|
||||||
|
for bal in balances:
|
||||||
|
if bal.get("type") == balance_type:
|
||||||
|
return bal.get("amount")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_extraction(
|
||||||
|
label: str,
|
||||||
|
input_file: str,
|
||||||
|
extractor: CreditExtractor | DebitExtractor,
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Run extractor, save JSON output, and return (raw_txns, extracted_objects)."""
|
||||||
|
input_path = Path(input_file)
|
||||||
|
extracted = extractor.extract_from_file(input_path)
|
||||||
|
output_path = _default_output_path(input_path)
|
||||||
|
|
||||||
|
with output_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(extracted, f, indent=2)
|
||||||
|
|
||||||
|
print(f"[{label}] Extracted {len(extracted)} object(s) to {output_path}")
|
||||||
|
|
||||||
|
raw_txns = _extract_raw_transactions(extracted)
|
||||||
|
print(f"[{label}] Found {len(raw_txns)} raw transaction(s)")
|
||||||
|
|
||||||
|
if extractor.errors:
|
||||||
|
print(f"[{label}] Errors:")
|
||||||
|
for error in extractor.errors:
|
||||||
|
print(f" - {error}")
|
||||||
|
|
||||||
|
return raw_txns, extracted
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ynab_transactions(days: int) -> None:
|
||||||
|
"""Fetch and display YNAB transactions for the past N days, excluding reconciled."""
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
transactions = client.get_transactions_for_days(
|
||||||
|
days=days,
|
||||||
|
exclude_cleared_statuses=["reconciled"],
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[ynab] Found {len(transactions)} transaction(s) "
|
||||||
|
f"for the past {days} day(s), excluding reconciled"
|
||||||
|
)
|
||||||
|
print(json.dumps(transactions, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_wf_from_date(wf_transactions: list[dict], start_iso: str) -> list[dict]:
|
||||||
|
"""Keep only WF transactions whose transactionDate is on or after start_iso."""
|
||||||
|
return [
|
||||||
|
txn for txn in wf_transactions
|
||||||
|
if _wf_date_to_iso(txn["transactionDate"]) >= start_iso
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_new_transactions(
|
||||||
|
wf_transactions: list[dict],
|
||||||
|
ynab_transactions: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return WF transactions that do NOT already exist in YNAB.
|
||||||
|
|
||||||
|
Match on (date, abs(amount_cents)):
|
||||||
|
- WF: transactionDate (ms) -> YYYY-MM-DD, transactionAmount * 100 -> cents
|
||||||
|
- YNAB: date (YYYY-MM-DD), abs(amount_milliCurrency / 10) -> cents
|
||||||
|
Uses a multiset so duplicate amounts on the same date are handled correctly.
|
||||||
|
"""
|
||||||
|
ynab_keys: dict[tuple[str, int], int] = {}
|
||||||
|
for txn in ynab_transactions:
|
||||||
|
key = (txn["date"], abs(_ynab_amount_to_cents(txn["amount_milliCurrency"])))
|
||||||
|
ynab_keys[key] = ynab_keys.get(key, 0) + 1
|
||||||
|
|
||||||
|
new_transactions: list[dict] = []
|
||||||
|
for txn in wf_transactions:
|
||||||
|
date_str = _wf_date_to_iso(txn["transactionDate"])
|
||||||
|
cents = abs(_wf_amount_to_cents(txn["transactionAmount"]))
|
||||||
|
key = (date_str, cents)
|
||||||
|
|
||||||
|
if ynab_keys.get(key, 0) > 0:
|
||||||
|
ynab_keys[key] -= 1
|
||||||
|
else:
|
||||||
|
new_transactions.append(txn)
|
||||||
|
|
||||||
|
return new_transactions
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# YNAB Import helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _wf_to_ynab_millis(wf_txn: dict) -> int:
|
||||||
|
"""Convert a WF transaction amount to YNAB milliCurrency.
|
||||||
|
|
||||||
|
Sign logic (unified for credit & debit cards):
|
||||||
|
- debitCreditType == "CREDIT" → inflow (positive)
|
||||||
|
- absent or "DEBIT" → outflow (negative)
|
||||||
|
"""
|
||||||
|
amount = wf_txn["transactionAmount"]
|
||||||
|
millis = round(amount * 1000)
|
||||||
|
if wf_txn.get("debitCreditType") == "CREDIT":
|
||||||
|
return millis # inflow
|
||||||
|
return -millis # outflow
|
||||||
|
|
||||||
|
|
||||||
|
def _build_import_ids(
|
||||||
|
amounts: list[int],
|
||||||
|
dates: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build YNAB import_id strings: ``YNAB:{amount}:{date}:{occurrence}``.
|
||||||
|
|
||||||
|
Occurrence starts at 1 and increments for duplicate (amount, date) pairs
|
||||||
|
within the batch.
|
||||||
|
"""
|
||||||
|
seen: dict[tuple[int, str], int] = {}
|
||||||
|
import_ids: list[str] = []
|
||||||
|
for amount, date_str in zip(amounts, dates):
|
||||||
|
key = (amount, date_str)
|
||||||
|
occurrence = seen.get(key, 0) + 1
|
||||||
|
seen[key] = occurrence
|
||||||
|
import_ids.append(f"YNAB:{amount}:{date_str}:{occurrence}")
|
||||||
|
return import_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_wf_to_ynab_transactions(
|
||||||
|
wf_transactions: list[dict],
|
||||||
|
account_id: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Convert raw WF transactions to YNAB-ready dicts.
|
||||||
|
|
||||||
|
Each dict has the keys expected by ``ynab.NewTransaction`` plus a
|
||||||
|
``_wf_source`` reference back to the original WF transaction.
|
||||||
|
"""
|
||||||
|
amounts: list[int] = []
|
||||||
|
dates: list[str] = []
|
||||||
|
for txn in wf_transactions:
|
||||||
|
amounts.append(_wf_to_ynab_millis(txn))
|
||||||
|
dates.append(_wf_date_to_iso(txn["transactionDate"]))
|
||||||
|
|
||||||
|
import_ids = _build_import_ids(amounts, dates)
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for txn, amount, date_str, import_id in zip(
|
||||||
|
wf_transactions, amounts, dates, import_ids
|
||||||
|
):
|
||||||
|
status = txn.get("status", "POSTED")
|
||||||
|
cleared = "cleared" if status == "POSTED" else "uncleared"
|
||||||
|
payee = " ".join(txn.get("transactionDescription", "").split())
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"account_id": account_id,
|
||||||
|
"date": date_str,
|
||||||
|
"amount": amount,
|
||||||
|
"payee_name": payee[:200] if payee else None,
|
||||||
|
"cleared": cleared,
|
||||||
|
"flag_color": "blue",
|
||||||
|
"import_id": import_id,
|
||||||
|
"approved": False,
|
||||||
|
"_wf_source": txn,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class _ImportClassification:
|
||||||
|
"""Holds the three buckets produced by ``_classify_for_import``."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.new: list[dict] = []
|
||||||
|
self.pending_to_posted: list[tuple[dict, YNABTransaction]] = []
|
||||||
|
self.duplicates: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_for_import(
|
||||||
|
ynab_ready: list[dict],
|
||||||
|
ynab_transactions: list[YNABTransaction],
|
||||||
|
) -> _ImportClassification:
|
||||||
|
"""Classify incoming transactions into new / pending→posted / duplicate.
|
||||||
|
|
||||||
|
Matching rules (all use (date, abs(amount)) via multiset):
|
||||||
|
1. If a posted incoming txn matches an **uncleared** YNAB txn
|
||||||
|
→ pending→posted (update existing to cleared).
|
||||||
|
2. If a cleared YNAB txn already has the same import_id
|
||||||
|
→ duplicate (create with RED flag).
|
||||||
|
3. Otherwise → new.
|
||||||
|
"""
|
||||||
|
result = _ImportClassification()
|
||||||
|
|
||||||
|
# Build a set of existing cleared import_ids
|
||||||
|
cleared_import_ids: set[str] = set()
|
||||||
|
for t in ynab_transactions:
|
||||||
|
if t["cleared"] in ("cleared", "reconciled") and t.get("import_id"):
|
||||||
|
cleared_import_ids.add(t["import_id"])
|
||||||
|
|
||||||
|
# Build a multiset of uncleared YNAB txns keyed by (date, abs_amount)
|
||||||
|
# Each entry stores the list of YNAB transactions for that key.
|
||||||
|
uncleared_pool: dict[tuple[str, int], list[YNABTransaction]] = {}
|
||||||
|
for t in ynab_transactions:
|
||||||
|
if t["cleared"] == "uncleared":
|
||||||
|
key = (t["date"], abs(t["amount_milliCurrency"]))
|
||||||
|
uncleared_pool.setdefault(key, []).append(t)
|
||||||
|
|
||||||
|
for txn in ynab_ready:
|
||||||
|
import_id = txn["import_id"]
|
||||||
|
abs_amount = abs(txn["amount"])
|
||||||
|
txn_key = (txn["date"], abs_amount)
|
||||||
|
|
||||||
|
# 1. Pending→Posted: incoming is cleared, matches an uncleared YNAB txn
|
||||||
|
if txn["cleared"] == "cleared" and uncleared_pool.get(txn_key):
|
||||||
|
matched_ynab = uncleared_pool[txn_key].pop(0)
|
||||||
|
if not uncleared_pool[txn_key]:
|
||||||
|
del uncleared_pool[txn_key]
|
||||||
|
result.pending_to_posted.append((txn, matched_ynab))
|
||||||
|
|
||||||
|
# 2. Duplicate: import_id already exists on a cleared YNAB txn
|
||||||
|
elif import_id in cleared_import_ids:
|
||||||
|
txn["flag_color"] = "red"
|
||||||
|
result.duplicates.append(txn)
|
||||||
|
|
||||||
|
# 3. New
|
||||||
|
else:
|
||||||
|
result.new.append(txn)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _format_amount_dollars(millis: int) -> str:
|
||||||
|
"""Format milliCurrency as a dollar string with sign."""
|
||||||
|
dollars = millis / 1000.0
|
||||||
|
return f"${dollars:+,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_import(classification: _ImportClassification) -> None:
|
||||||
|
"""Print a formatted preview of the import classification."""
|
||||||
|
col_date = 12
|
||||||
|
col_amount = 14
|
||||||
|
col_desc = 50
|
||||||
|
header = f" {'Date':<{col_date}} {'Amount':>{col_amount}} {'Description':<{col_desc}}"
|
||||||
|
separator = f" {'-' * col_date} {'-' * col_amount} {'-' * col_desc}"
|
||||||
|
|
||||||
|
# --- New ---
|
||||||
|
print(f"\n{'=' * 80}")
|
||||||
|
print(f" NEW TRANSACTIONS ({len(classification.new)}) — will be created with BLUE flag")
|
||||||
|
print(f"{'=' * 80}")
|
||||||
|
if classification.new:
|
||||||
|
print(header)
|
||||||
|
print(separator)
|
||||||
|
for txn in classification.new:
|
||||||
|
desc = (txn.get("payee_name") or "")[:col_desc]
|
||||||
|
print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}")
|
||||||
|
else:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
# --- Pending→Posted ---
|
||||||
|
print(f"\n{'=' * 80}")
|
||||||
|
print(f" PENDING → POSTED ({len(classification.pending_to_posted)}) — existing uncleared will be marked cleared")
|
||||||
|
print(f"{'=' * 80}")
|
||||||
|
if classification.pending_to_posted:
|
||||||
|
print(header)
|
||||||
|
print(separator)
|
||||||
|
for txn, matched in classification.pending_to_posted:
|
||||||
|
desc = (txn.get("payee_name") or "")[:col_desc]
|
||||||
|
print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}")
|
||||||
|
else:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
# --- Duplicates ---
|
||||||
|
print(f"\n{'=' * 80}")
|
||||||
|
print(f" POSSIBLE DUPLICATES ({len(classification.duplicates)}) — will be created with RED flag")
|
||||||
|
print(f"{'=' * 80}")
|
||||||
|
if classification.duplicates:
|
||||||
|
print(header)
|
||||||
|
print(separator)
|
||||||
|
for txn in classification.duplicates:
|
||||||
|
desc = (txn.get("payee_name") or "")[:col_desc]
|
||||||
|
print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}")
|
||||||
|
else:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
total = (
|
||||||
|
len(classification.new)
|
||||||
|
+ len(classification.pending_to_posted)
|
||||||
|
+ len(classification.duplicates)
|
||||||
|
)
|
||||||
|
print(f"\n Total: {total} transaction(s)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_import(
|
||||||
|
classification: _ImportClassification,
|
||||||
|
ynab_client: YNABClient,
|
||||||
|
) -> None:
|
||||||
|
"""Send classified transactions to YNAB."""
|
||||||
|
|
||||||
|
# --- Create new + duplicates ---
|
||||||
|
to_create = classification.new + classification.duplicates
|
||||||
|
if to_create:
|
||||||
|
new_txns = [
|
||||||
|
ynab.NewTransaction(
|
||||||
|
account_id=t["account_id"],
|
||||||
|
date=date.fromisoformat(t["date"]),
|
||||||
|
amount=t["amount"],
|
||||||
|
payee_name=t["payee_name"],
|
||||||
|
cleared=ynab.TransactionClearedStatus(t["cleared"]),
|
||||||
|
flag_color=ynab.TransactionFlagColor(t["flag_color"]),
|
||||||
|
import_id=t["import_id"],
|
||||||
|
approved=t.get("approved", False),
|
||||||
|
)
|
||||||
|
for t in to_create
|
||||||
|
]
|
||||||
|
resp = ynab_client.create_transactions(new_txns)
|
||||||
|
created_ids = resp.transaction_ids or []
|
||||||
|
dup_ids = resp.duplicate_import_ids or []
|
||||||
|
print(
|
||||||
|
f" [ynab] Created {len(created_ids)} transaction(s)"
|
||||||
|
+ (f", {len(dup_ids)} server-side duplicate(s) skipped" if dup_ids else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Update pending→posted ---
|
||||||
|
if classification.pending_to_posted:
|
||||||
|
updated = 0
|
||||||
|
for _txn, matched_ynab in classification.pending_to_posted:
|
||||||
|
try:
|
||||||
|
ynab_client.update_transaction(
|
||||||
|
transaction_id=matched_ynab["id"],
|
||||||
|
cleared="cleared",
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ynab] Error updating {matched_ynab['id']}: {e}")
|
||||||
|
print(f" [ynab] Updated {updated} transaction(s) from uncleared → cleared")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Extract WF HAR files, compare with YNAB, find new transactions"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--credit",
|
||||||
|
default="",
|
||||||
|
help="Optional credit HAR input file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debit",
|
||||||
|
default="",
|
||||||
|
help="Optional debit HAR input file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lookback",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
metavar="DAYS",
|
||||||
|
help="Days before last reconciled YNAB transaction to start comparison (default: 3)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ynab-transactions",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
metavar="DAYS",
|
||||||
|
help="List YNAB transactions for the past DAYS (excluding reconciled)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ynab-import",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Import new WF transactions into YNAB (requires --credit and/or --debit)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load YNAB config to map extractors -> account IDs
|
||||||
|
ynab_client = YNABClient("ynab.yml")
|
||||||
|
account_configs = ynab_client.account_configs
|
||||||
|
|
||||||
|
# Build extractor_name -> [account_id, ...] mapping from ynab.yml
|
||||||
|
extractor_to_accounts: dict[str, list[str]] = {}
|
||||||
|
for acct in account_configs:
|
||||||
|
ext_name = acct.get("extractor", "")
|
||||||
|
if ext_name:
|
||||||
|
extractor_to_accounts.setdefault(ext_name, []).append(acct["id"])
|
||||||
|
|
||||||
|
# -- Extract WF transactions -------------------------------------------
|
||||||
|
extracted_by_extractor: dict[str, list[dict]] = {}
|
||||||
|
objects_by_extractor: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
for flag, extractor_name in FLAG_TO_EXTRACTOR.items():
|
||||||
|
input_file = getattr(args, flag, "")
|
||||||
|
if not input_file:
|
||||||
|
continue
|
||||||
|
extractor_cls = EXTRACTOR_MAP[extractor_name]
|
||||||
|
raw_txns, extracted_objects = _run_extraction(flag, input_file, extractor_cls())
|
||||||
|
extracted_by_extractor[extractor_name] = raw_txns
|
||||||
|
objects_by_extractor[extractor_name] = extracted_objects
|
||||||
|
|
||||||
|
# -- Compare with YNAB -------------------------------------------------
|
||||||
|
if extracted_by_extractor:
|
||||||
|
# Gather all relevant account IDs across active extractors
|
||||||
|
all_account_ids = [
|
||||||
|
aid
|
||||||
|
for ext_name in extracted_by_extractor
|
||||||
|
for aid in extractor_to_accounts.get(ext_name, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find the last reconciled transaction date in YNAB
|
||||||
|
last_reconciled = ynab_client.get_last_reconciled_date(
|
||||||
|
account_ids=all_account_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_reconciled is None:
|
||||||
|
print("\n[ynab] No reconciled transactions found; defaulting to 30 day lookback")
|
||||||
|
start_date = (datetime.now() - timedelta(days=30)).date()
|
||||||
|
else:
|
||||||
|
start_date = last_reconciled - timedelta(days=args.lookback)
|
||||||
|
print(
|
||||||
|
f"\n[ynab] Last reconciled date: {last_reconciled}"
|
||||||
|
f" → start comparison from {start_date} "
|
||||||
|
f"({args.lookback} day lookback)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_iso = start_date.isoformat()
|
||||||
|
days_from_start = (datetime.now().date() - start_date).days + 1
|
||||||
|
|
||||||
|
# Fetch ALL YNAB transactions from start_date onward (including reconciled)
|
||||||
|
# so that reconciled matches also get consumed during comparison.
|
||||||
|
all_ynab_txns = ynab_client.get_transactions_for_days(
|
||||||
|
days=days_from_start,
|
||||||
|
)
|
||||||
|
print(f"[ynab] Retrieved {len(all_ynab_txns)} transaction(s) from {start_iso} onward")
|
||||||
|
|
||||||
|
# Compare per-extractor, scoped to the matching YNAB account IDs
|
||||||
|
for extractor_name, wf_txns in extracted_by_extractor.items():
|
||||||
|
account_ids = set(extractor_to_accounts.get(extractor_name, []))
|
||||||
|
scoped_ynab = [t for t in all_ynab_txns if t["account_id"] in account_ids]
|
||||||
|
|
||||||
|
# Filter WF transactions to the same start date
|
||||||
|
filtered_wf = _filter_wf_from_date(wf_txns, start_iso)
|
||||||
|
|
||||||
|
new_txns = _find_new_transactions(filtered_wf, scoped_ynab)
|
||||||
|
|
||||||
|
label = extractor_name.replace("wf_", "").replace("_extractor", "")
|
||||||
|
print(
|
||||||
|
f"\n[{label}] {len(new_txns)} new transaction(s) "
|
||||||
|
f"not yet in YNAB (of {len(filtered_wf)} extracted from {start_iso}, "
|
||||||
|
f"{len(scoped_ynab)} in YNAB)"
|
||||||
|
)
|
||||||
|
if new_txns:
|
||||||
|
print(json.dumps(new_txns, indent=2))
|
||||||
|
|
||||||
|
# -- YNAB Import -------------------------------------------------------
|
||||||
|
if args.ynab_import and extracted_by_extractor:
|
||||||
|
all_ynab_ready: list[dict] = []
|
||||||
|
balances: dict[str, float] = {} # label -> balance amount
|
||||||
|
|
||||||
|
for extractor_name, wf_txns in extracted_by_extractor.items():
|
||||||
|
account_ids_for_ext = extractor_to_accounts.get(extractor_name, [])
|
||||||
|
if not account_ids_for_ext:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use the first account ID for this extractor
|
||||||
|
target_account_id = account_ids_for_ext[0]
|
||||||
|
scoped_ynab = [
|
||||||
|
t for t in all_ynab_txns if t["account_id"] in set(account_ids_for_ext)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filter WF to the comparison window, then find only new ones
|
||||||
|
filtered_wf = _filter_wf_from_date(wf_txns, start_iso)
|
||||||
|
new_wf = _find_new_transactions(filtered_wf, scoped_ynab)
|
||||||
|
|
||||||
|
# Convert the new WF transactions to YNAB-ready dicts
|
||||||
|
ynab_ready = _convert_wf_to_ynab_transactions(new_wf, target_account_id)
|
||||||
|
|
||||||
|
# Classify against existing YNAB transactions
|
||||||
|
classification = _classify_for_import(ynab_ready, scoped_ynab)
|
||||||
|
|
||||||
|
label = extractor_name.replace("wf_", "").replace("_extractor", "")
|
||||||
|
print(f"\n--- {label.upper()} IMPORT PREVIEW ---")
|
||||||
|
|
||||||
|
# Collect account balance for bottom-of-report summary
|
||||||
|
balance_type = EXTRACTOR_BALANCE_TYPE.get(extractor_name)
|
||||||
|
if balance_type:
|
||||||
|
balance = _extract_account_balance(
|
||||||
|
objects_by_extractor.get(extractor_name, []),
|
||||||
|
balance_type,
|
||||||
|
)
|
||||||
|
if balance is not None:
|
||||||
|
balances[label] = balance
|
||||||
|
|
||||||
|
_preview_import(classification)
|
||||||
|
|
||||||
|
all_ynab_ready.append({
|
||||||
|
"label": label,
|
||||||
|
"classification": classification,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Display account balances at the bottom of the report
|
||||||
|
if balances:
|
||||||
|
parts = []
|
||||||
|
for lbl, amt in balances.items():
|
||||||
|
parts.append(f"{lbl.title()} ${amt:,.2f}")
|
||||||
|
print(f"{'=' * 80}")
|
||||||
|
print(" " + " ".join(parts))
|
||||||
|
print(f"{'=' * 80}\n")
|
||||||
|
|
||||||
|
# Check if there's anything to import
|
||||||
|
has_work = any(
|
||||||
|
entry["classification"].new
|
||||||
|
or entry["classification"].pending_to_posted
|
||||||
|
or entry["classification"].duplicates
|
||||||
|
for entry in all_ynab_ready
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_work:
|
||||||
|
answer = input("Proceed with import? [y/N]: ").strip().lower()
|
||||||
|
if answer == "y":
|
||||||
|
for entry in all_ynab_ready:
|
||||||
|
label = entry["label"]
|
||||||
|
classification = entry["classification"]
|
||||||
|
print(f"\n[{label}] Importing...")
|
||||||
|
_execute_import(classification, ynab_client)
|
||||||
|
print("\n[ynab] Import complete.")
|
||||||
|
else:
|
||||||
|
print("\n[ynab] Import cancelled.")
|
||||||
|
else:
|
||||||
|
print("\n[ynab] Nothing to import.")
|
||||||
|
|
||||||
|
elif args.ynab_import and not extracted_by_extractor:
|
||||||
|
print("[ynab-import] No extracted transactions. Use --credit and/or --debit.")
|
||||||
|
|
||||||
|
# -- Standalone YNAB listing -------------------------------------------
|
||||||
|
if args.ynab_transactions is not None:
|
||||||
|
_run_ynab_transactions(args.ynab_transactions)
|
||||||
|
|
||||||
|
if not args.credit and not args.debit and args.ynab_transactions is None:
|
||||||
|
print("No input provided. Use --credit, --debit, --ynab-import, and/or --ynab-transactions.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
116
reconcile.py
Normal file
116
reconcile.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Integration module to combine WellsFargo and YNAB transactions for reconciliation."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from wf_debit_extractor import WellsFargoPayloadExtractor
|
||||||
|
from transaction_processor import TransactionProcessor
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationReport(TypedDict):
|
||||||
|
"""Summary of WellsFargo and YNAB data."""
|
||||||
|
|
||||||
|
timestamp: str
|
||||||
|
wellsfargo_transactions: list[dict]
|
||||||
|
ynab_transactions: list[dict]
|
||||||
|
wellsfargo_total: float
|
||||||
|
ynab_total: float
|
||||||
|
transaction_count_wf: int
|
||||||
|
transaction_count_ynab: int
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_transactions() -> ReconciliationReport:
|
||||||
|
"""
|
||||||
|
Fetch and compare WellsFargo and YNAB transactions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reconciliation report with both datasets
|
||||||
|
"""
|
||||||
|
print("Fetching WellsFargo transactions...")
|
||||||
|
wf_extractor = WellsFargoPayloadExtractor()
|
||||||
|
wf_payloads = wf_extractor.extract_from_file("posted.dat")
|
||||||
|
|
||||||
|
wf_processor = TransactionProcessor()
|
||||||
|
wf_transactions = wf_processor.extract_transactions(wf_payloads)
|
||||||
|
|
||||||
|
print(f"Found {len(wf_transactions)} WellsFargo transaction(s)")
|
||||||
|
|
||||||
|
print("\nFetching YNAB transactions...")
|
||||||
|
ynab_client = YNABClient("ynab.yml")
|
||||||
|
ynab_transactions = ynab_client.get_transactions_for_days(days=30)
|
||||||
|
|
||||||
|
print(f"Found {len(ynab_transactions)} YNAB transaction(s)")
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
wf_total = sum(txn["transaction_amount_cents"] for txn in wf_transactions) / 100
|
||||||
|
ynab_total = sum(txn["amount_milliCurrency"] for txn in ynab_transactions) / 1000
|
||||||
|
|
||||||
|
# Serialize transactions for JSON output
|
||||||
|
wf_serialized = [
|
||||||
|
{
|
||||||
|
"id": txn["id"],
|
||||||
|
"amount": txn["transaction_amount_cents"] / 100,
|
||||||
|
"amount_cents": txn["transaction_amount_cents"],
|
||||||
|
"date": txn["transaction_date_datetime"].isoformat(),
|
||||||
|
"post_date": txn["post_date_datetime"].isoformat(),
|
||||||
|
"description": txn["transaction_description"],
|
||||||
|
}
|
||||||
|
for txn in wf_transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
ynab_serialized = [
|
||||||
|
{
|
||||||
|
"id": txn["id"],
|
||||||
|
"amount": txn["amount_milliCurrency"] / 1000,
|
||||||
|
"amount_milliCurrency": txn["amount_milliCurrency"],
|
||||||
|
"date": txn["date"],
|
||||||
|
"payee": txn["payee_name"],
|
||||||
|
"category": txn["category_name"],
|
||||||
|
"account": txn["account_name"],
|
||||||
|
}
|
||||||
|
for txn in ynab_transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
report: ReconciliationReport = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"wellsfargo_transactions": wf_serialized,
|
||||||
|
"ynab_transactions": ynab_serialized,
|
||||||
|
"wellsfargo_total": wf_total,
|
||||||
|
"ynab_total": ynab_total,
|
||||||
|
"transaction_count_wf": len(wf_transactions),
|
||||||
|
"transaction_count_ynab": len(ynab_transactions),
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run full reconciliation and save report."""
|
||||||
|
report = reconcile_transactions()
|
||||||
|
|
||||||
|
# Display summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("RECONCILIATION SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"WellsFargo transactions: {report['transaction_count_wf']}")
|
||||||
|
print(f"WellsFargo total: ${report['wellsfargo_total']:.2f}")
|
||||||
|
print(f"\nYNAB transactions: {report['transaction_count_ynab']}")
|
||||||
|
print(f"YNAB total: ${report['ynab_total']:.2f}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Save report
|
||||||
|
output_file = Path("reconciliation_report.json")
|
||||||
|
with output_file.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nReport saved to {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
152
test_import.py
Normal file
152
test_import.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for the YNAB import helpers in main.py."""
|
||||||
|
from main import (
|
||||||
|
_wf_to_ynab_millis, _build_import_ids, _convert_wf_to_ynab_transactions,
|
||||||
|
_classify_for_import, _format_amount_dollars, _preview_import,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_to_ynab_millis():
|
||||||
|
# Credit card charge (no debitCreditType) -> outflow (negative)
|
||||||
|
assert _wf_to_ynab_millis({"transactionAmount": 9.11}) == -9110
|
||||||
|
# Credit card refund (debitCreditType=CREDIT) -> inflow (positive)
|
||||||
|
assert _wf_to_ynab_millis({"transactionAmount": 1.64, "debitCreditType": "CREDIT"}) == 1640
|
||||||
|
# Debit card charge (debitCreditType=DEBIT) -> outflow (negative)
|
||||||
|
assert _wf_to_ynab_millis({"transactionAmount": 194.32, "debitCreditType": "DEBIT"}) == -194320
|
||||||
|
# Debit card credit (debitCreditType=CREDIT) -> inflow (positive)
|
||||||
|
assert _wf_to_ynab_millis({"transactionAmount": 225.0, "debitCreditType": "CREDIT"}) == 225000
|
||||||
|
# Pending (no debitCreditType) -> outflow (negative)
|
||||||
|
assert _wf_to_ynab_millis({"transactionAmount": 2.48}) == -2480
|
||||||
|
print("test_wf_to_ynab_millis: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_import_ids():
|
||||||
|
amounts = [-9110, -9110, -5000]
|
||||||
|
dates = ["2026-03-18", "2026-03-18", "2026-03-17"]
|
||||||
|
ids = _build_import_ids(amounts, dates)
|
||||||
|
assert ids == [
|
||||||
|
"YNAB:-9110:2026-03-18:1",
|
||||||
|
"YNAB:-9110:2026-03-18:2",
|
||||||
|
"YNAB:-5000:2026-03-17:1",
|
||||||
|
], f"Got {ids}"
|
||||||
|
print("test_build_import_ids: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_amount_dollars():
|
||||||
|
assert _format_amount_dollars(-9110) == "$-9.11"
|
||||||
|
assert _format_amount_dollars(225000) == "$+225.00"
|
||||||
|
assert _format_amount_dollars(-194320) == "$-194.32"
|
||||||
|
print("test_format_amount_dollars: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_wf_to_ynab_transactions():
|
||||||
|
wf_txns = [
|
||||||
|
{
|
||||||
|
"transactionAmount": 9.11,
|
||||||
|
"transactionDate": 1773817200000,
|
||||||
|
"transactionDescription": "WALGREENS #10276 MORRISVILLE NC",
|
||||||
|
"status": "POSTED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transactionAmount": 2.48,
|
||||||
|
"transactionDate": 1774076400000,
|
||||||
|
"transactionDescription": "CULVERS",
|
||||||
|
"status": "PENDING",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
|
||||||
|
|
||||||
|
assert result[0]["amount"] == -9110
|
||||||
|
assert result[0]["cleared"] == "cleared"
|
||||||
|
assert result[0]["flag_color"] == "blue"
|
||||||
|
assert result[0]["account_id"] == "acct-123"
|
||||||
|
assert result[0]["payee_name"] == "WALGREENS #10276 MORRISVILLE NC"
|
||||||
|
assert "YNAB:-9110:" in result[0]["import_id"]
|
||||||
|
|
||||||
|
assert result[1]["amount"] == -2480
|
||||||
|
assert result[1]["cleared"] == "uncleared"
|
||||||
|
assert result[1]["flag_color"] == "blue"
|
||||||
|
print("test_convert_wf_to_ynab_transactions: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_for_import():
|
||||||
|
wf_txns = [
|
||||||
|
{
|
||||||
|
"transactionAmount": 9.11,
|
||||||
|
"transactionDate": 1773817200000,
|
||||||
|
"transactionDescription": "WALGREENS #10276",
|
||||||
|
"status": "POSTED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transactionAmount": 2.48,
|
||||||
|
"transactionDate": 1774076400000,
|
||||||
|
"transactionDescription": "CULVERS",
|
||||||
|
"status": "PENDING",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transactionAmount": 50.00,
|
||||||
|
"transactionDate": 1773817200000,
|
||||||
|
"transactionDescription": "NEW TXN",
|
||||||
|
"status": "POSTED",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
|
||||||
|
|
||||||
|
# Derive dates to build matching YNAB transactions
|
||||||
|
from main import _wf_date_to_iso
|
||||||
|
date0 = _wf_date_to_iso(1773817200000)
|
||||||
|
date1 = _wf_date_to_iso(1774076400000)
|
||||||
|
|
||||||
|
ynab_txns = [
|
||||||
|
# Uncleared txn matching posted WALGREENS -> pending_to_posted
|
||||||
|
{
|
||||||
|
"id": "ynab-1", "date": date0, "payee_name": "WALGREENS",
|
||||||
|
"category_name": None, "amount_milliCurrency": -9110, "memo": None,
|
||||||
|
"account_id": "acct-123", "account_name": "Credit", "cleared": "uncleared",
|
||||||
|
"import_id": "YNAB:-9110:" + date0 + ":1",
|
||||||
|
},
|
||||||
|
# Cleared txn with same import_id as CULVERS -> duplicate
|
||||||
|
{
|
||||||
|
"id": "ynab-2", "date": date1, "payee_name": "CULVERS",
|
||||||
|
"category_name": None, "amount_milliCurrency": -2480, "memo": None,
|
||||||
|
"account_id": "acct-123", "account_name": "Credit", "cleared": "cleared",
|
||||||
|
"import_id": "YNAB:-2480:" + date1 + ":1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
classification = _classify_for_import(ynab_ready, ynab_txns)
|
||||||
|
|
||||||
|
assert len(classification.pending_to_posted) == 1
|
||||||
|
assert classification.pending_to_posted[0][1]["id"] == "ynab-1"
|
||||||
|
|
||||||
|
assert len(classification.duplicates) == 1
|
||||||
|
assert classification.duplicates[0]["flag_color"] == "red"
|
||||||
|
|
||||||
|
assert len(classification.new) == 1
|
||||||
|
assert classification.new[0]["payee_name"] == "NEW TXN"
|
||||||
|
|
||||||
|
print("test_classify_for_import: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview():
|
||||||
|
"""Visual test - just make sure it doesn't crash."""
|
||||||
|
wf_txns = [
|
||||||
|
{"transactionAmount": 9.11, "transactionDate": 1773817200000,
|
||||||
|
"transactionDescription": "WALGREENS #10276", "status": "POSTED"},
|
||||||
|
{"transactionAmount": 50.00, "transactionDate": 1773817200000,
|
||||||
|
"transactionDescription": "NEW TXN", "status": "POSTED"},
|
||||||
|
]
|
||||||
|
ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
|
||||||
|
classification = _classify_for_import(ynab_ready, [])
|
||||||
|
_preview_import(classification)
|
||||||
|
print("test_preview: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_wf_to_ynab_millis()
|
||||||
|
test_build_import_ids()
|
||||||
|
test_format_amount_dollars()
|
||||||
|
test_convert_wf_to_ynab_transactions()
|
||||||
|
test_classify_for_import()
|
||||||
|
test_preview()
|
||||||
|
print("\nALL TESTS PASSED")
|
||||||
|
|
||||||
77
transaction_processor.py
Normal file
77
transaction_processor.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedTransaction(TypedDict):
|
||||||
|
"""Transaction data extracted and transformed to desired format."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
transaction_amount_cents: int
|
||||||
|
transaction_date_timestamp: int
|
||||||
|
transaction_date_datetime: datetime
|
||||||
|
post_date_timestamp: int
|
||||||
|
post_date_datetime: datetime
|
||||||
|
transaction_description: str
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionProcessor:
|
||||||
|
"""Extract and transform transaction data from WellsFargo payload objects."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _timestamp_to_datetime(timestamp_ms: int) -> datetime:
|
||||||
|
"""Convert millisecond timestamp to datetime object."""
|
||||||
|
return datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _amount_to_cents(amount: float) -> int:
|
||||||
|
"""Convert dollar amount to cents (integer)."""
|
||||||
|
return round(amount * 100)
|
||||||
|
|
||||||
|
def extract_transactions(
|
||||||
|
self, extracted_objects: list[dict[str, Any]]
|
||||||
|
) -> list[ProcessedTransaction]:
|
||||||
|
"""
|
||||||
|
Extract and transform transactions from extracted WellsFargo payloads.
|
||||||
|
|
||||||
|
Path: /transactions/transactionData/transactions
|
||||||
|
"""
|
||||||
|
transactions: list[ProcessedTransaction] = []
|
||||||
|
|
||||||
|
for obj in extracted_objects:
|
||||||
|
# Navigate to transactions list
|
||||||
|
txn_list = (
|
||||||
|
obj.get("transactions", {})
|
||||||
|
.get("transactionData", {})
|
||||||
|
.get("transactions", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(txn_list, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for txn in txn_list:
|
||||||
|
if not isinstance(txn, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
processed: ProcessedTransaction = {
|
||||||
|
"id": txn["id"],
|
||||||
|
"transaction_amount_cents": self._amount_to_cents(
|
||||||
|
txn["transactionAmount"]
|
||||||
|
),
|
||||||
|
"transaction_date_timestamp": txn["transactionDate"],
|
||||||
|
"transaction_date_datetime": self._timestamp_to_datetime(
|
||||||
|
txn["transactionDate"]
|
||||||
|
),
|
||||||
|
"post_date_timestamp": txn["postDate"],
|
||||||
|
"post_date_datetime": self._timestamp_to_datetime(
|
||||||
|
txn["postDate"]
|
||||||
|
),
|
||||||
|
"transaction_description": " ".join(txn["transactionDescription"].split()),
|
||||||
|
}
|
||||||
|
transactions.append(processed)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
# Skip malformed transactions
|
||||||
|
continue
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
89
wf_credit_extractor.py
Normal file
89
wf_credit_extractor.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
START_MARKER = "/*WellFargoProprietary%"
|
||||||
|
END_MARKER = "%WellFargoProprietary*/"
|
||||||
|
|
||||||
|
|
||||||
|
class WellsFargoPayloadExtractor:
|
||||||
|
"""Extract wrapped WellFargo payloads from HAR entries and parse them as JSON."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: list[Any] = []
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def _normalize_text(self, raw_text: Any) -> str | None:
|
||||||
|
"""Return a normalized text payload or None when input is not a string."""
|
||||||
|
if not isinstance(raw_text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = raw_text.strip()
|
||||||
|
|
||||||
|
# Some HAR exports store content.text as an escaped JSON string literal.
|
||||||
|
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, str):
|
||||||
|
text = decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.errors.append("Could not decode an escaped text wrapper")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def extract_from_data(self, har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Parse and store objects from log.entries[*].response.content.text."""
|
||||||
|
self.objects = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
entries = har_data.get("log", {}).get("entries", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
self.errors.append("Expected log.entries to be a list")
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
text_value = (
|
||||||
|
entry.get("response", {})
|
||||||
|
.get("content", {})
|
||||||
|
.get("text")
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
text = self._normalize_text(text_value)
|
||||||
|
if not text or not text.startswith(START_MARKER):
|
||||||
|
continue
|
||||||
|
|
||||||
|
end_idx = text.find(END_MARKER, len(START_MARKER))
|
||||||
|
if end_idx == -1:
|
||||||
|
self.errors.append(f"Entry {index}: missing end marker")
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = text[len(START_MARKER) : end_idx]
|
||||||
|
if not payload:
|
||||||
|
self.errors.append(f"Entry {index}: payload between markers is empty")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.objects.append(json.loads(payload))
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
self.errors.append(f"Entry {index}: invalid payload JSON ({ex.msg})")
|
||||||
|
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
def extract_from_file(self, input_path: str | Path) -> list[Any]:
|
||||||
|
"""Load HAR JSON from a file path and extract payload objects."""
|
||||||
|
path = Path(input_path)
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
har_data = json.load(f)
|
||||||
|
return self.extract_from_data(har_data)
|
||||||
|
|
||||||
|
def get_objects(self) -> list[Any]:
|
||||||
|
"""Return the latest parsed objects."""
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wf_objects(har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Backward-compatible functional API."""
|
||||||
|
return WellsFargoPayloadExtractor().extract_from_data(har_data)
|
||||||
|
|
||||||
|
|
||||||
106
wf_debit_extractor.py
Normal file
106
wf_debit_extractor.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
START_MARKER = "/*WellFargoProprietary%"
|
||||||
|
END_MARKER = "%WellFargoProprietary*/"
|
||||||
|
|
||||||
|
|
||||||
|
class WellsFargoPayloadExtractor:
|
||||||
|
"""Extract wrapped WellFargo payloads from HAR entries and parse them as JSON."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: list[Any] = []
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def _normalize_text(self, raw_text: Any) -> str | None:
|
||||||
|
"""Return a normalized text payload or None when input is not a string."""
|
||||||
|
if not isinstance(raw_text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = raw_text.strip()
|
||||||
|
|
||||||
|
# Some HAR exports store content.text as an escaped JSON string literal.
|
||||||
|
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, str):
|
||||||
|
text = decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.errors.append("Could not decode an escaped text wrapper")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_wrapped_text(self, text: str, context: str) -> Any | None:
|
||||||
|
"""Parse a marker-wrapped WellFargo payload text into JSON."""
|
||||||
|
if not text.startswith(START_MARKER):
|
||||||
|
return None
|
||||||
|
|
||||||
|
end_idx = text.find(END_MARKER, len(START_MARKER))
|
||||||
|
if end_idx == -1:
|
||||||
|
self.errors.append(f"{context}: missing end marker")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = text[len(START_MARKER) : end_idx]
|
||||||
|
if not payload:
|
||||||
|
self.errors.append(f"{context}: payload between markers is empty")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(payload)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
self.errors.append(f"{context}: invalid payload JSON ({ex.msg})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_from_data(
|
||||||
|
self, har_data: dict[str, Any], entry0_only: bool = False
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Parse payload objects from log.entries response/content/text fields."""
|
||||||
|
self.objects = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
entries = har_data.get("log", {}).get("entries", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
self.errors.append("Expected log.entries to be a list")
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
indexed_entries = list(enumerate(entries[:1] if entry0_only else entries))
|
||||||
|
|
||||||
|
for index, entry in indexed_entries:
|
||||||
|
text_value = (
|
||||||
|
entry.get("response", {})
|
||||||
|
.get("content", {})
|
||||||
|
.get("text")
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
text = self._normalize_text(text_value)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = self._parse_wrapped_text(text, f"Entry {index}")
|
||||||
|
if parsed is not None:
|
||||||
|
self.objects.append(parsed)
|
||||||
|
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
def extract_from_file(
|
||||||
|
self, input_path: str | Path, entry0_only: bool = False
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Load HAR JSON from a file path and extract payload objects."""
|
||||||
|
path = Path(input_path)
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
har_data = json.load(f)
|
||||||
|
return self.extract_from_data(har_data, entry0_only=entry0_only)
|
||||||
|
|
||||||
|
def get_objects(self) -> list[Any]:
|
||||||
|
"""Return the latest parsed objects."""
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wf_objects(har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Backward-compatible functional API."""
|
||||||
|
return WellsFargoPayloadExtractor().extract_from_data(har_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
105
wf_extractor.py
Normal file
105
wf_extractor.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
START_MARKER = "/*WellFargoProprietary%"
|
||||||
|
END_MARKER = "%WellFargoProprietary*/"
|
||||||
|
|
||||||
|
|
||||||
|
class WellsFargoPayloadExtractor:
|
||||||
|
"""Extract wrapped WellFargo payloads from HAR entries and parse them as JSON."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: list[Any] = []
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def _normalize_text(self, raw_text: Any) -> str | None:
|
||||||
|
"""Return a normalized text payload or None when input is not a string."""
|
||||||
|
if not isinstance(raw_text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = raw_text.strip()
|
||||||
|
|
||||||
|
# Some HAR exports store content.text as an escaped JSON string literal.
|
||||||
|
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, str):
|
||||||
|
text = decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.errors.append("Could not decode an escaped text wrapper")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_wrapped_text(self, text: str, context: str) -> Any | None:
|
||||||
|
"""Parse a marker-wrapped WellFargo payload text into JSON."""
|
||||||
|
if not text.startswith(START_MARKER):
|
||||||
|
return None
|
||||||
|
|
||||||
|
end_idx = text.find(END_MARKER, len(START_MARKER))
|
||||||
|
if end_idx == -1:
|
||||||
|
self.errors.append(f"{context}: missing end marker")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = text[len(START_MARKER) : end_idx]
|
||||||
|
if not payload:
|
||||||
|
self.errors.append(f"{context}: payload between markers is empty")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(payload)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
self.errors.append(f"{context}: invalid payload JSON ({ex.msg})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_from_data(
|
||||||
|
self, har_data: dict[str, Any], entry0_only: bool = False
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Parse payload objects from log.entries response/content/text fields."""
|
||||||
|
self.objects = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
entries = har_data.get("log", {}).get("entries", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
self.errors.append("Expected log.entries to be a list")
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
indexed_entries = list(enumerate(entries[:1] if entry0_only else entries))
|
||||||
|
|
||||||
|
for index, entry in indexed_entries:
|
||||||
|
text_value = (
|
||||||
|
entry.get("response", {})
|
||||||
|
.get("content", {})
|
||||||
|
.get("text")
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
text = self._normalize_text(text_value)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = self._parse_wrapped_text(text, f"Entry {index}")
|
||||||
|
if parsed is not None:
|
||||||
|
self.objects.append(parsed)
|
||||||
|
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
def extract_from_file(
|
||||||
|
self, input_path: str | Path, entry0_only: bool = False
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Load HAR JSON from a file path and extract payload objects."""
|
||||||
|
path = Path(input_path)
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
har_data = json.load(f)
|
||||||
|
return self.extract_from_data(har_data, entry0_only=entry0_only)
|
||||||
|
|
||||||
|
def get_objects(self) -> list[Any]:
|
||||||
|
"""Return the latest parsed objects."""
|
||||||
|
return self.objects
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wf_objects(har_data: dict[str, Any]) -> list[Any]:
|
||||||
|
"""Backward-compatible functional API."""
|
||||||
|
return WellsFargoPayloadExtractor().extract_from_data(har_data)
|
||||||
|
|
||||||
|
|
||||||
266
ynab_client.py
Normal file
266
ynab_client.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import yaml
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
import ynab
|
||||||
|
|
||||||
|
|
||||||
|
class YNABTransaction(TypedDict):
|
||||||
|
"""Transaction data from YNAB."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
date: str
|
||||||
|
payee_name: str | None
|
||||||
|
category_name: str | None
|
||||||
|
amount_milliCurrency: int
|
||||||
|
memo: str | None
|
||||||
|
account_id: str
|
||||||
|
account_name: str
|
||||||
|
cleared: str
|
||||||
|
import_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class YNABConfig(TypedDict):
|
||||||
|
"""Configuration loaded from ynab.yml."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
plan_id: str
|
||||||
|
accounts: list[dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class YNABClient:
|
||||||
|
"""Client for fetching transactions from YNAB."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str | Path = "ynab.yml") -> None:
|
||||||
|
"""Initialize YNAB client from config file."""
|
||||||
|
self.config = self._load_config(config_path)
|
||||||
|
self.token = self.config["token"]
|
||||||
|
self.plan_id = self.config["plan_id"]
|
||||||
|
self.account_configs = self.config["accounts"]
|
||||||
|
|
||||||
|
# Use SDK-native access_token auth for bearer token handling.
|
||||||
|
config = ynab.Configuration(access_token=self.token)
|
||||||
|
self.api_client = ynab.ApiClient(config)
|
||||||
|
self.transactions_api = ynab.TransactionsApi(self.api_client)
|
||||||
|
self.accounts_api = ynab.AccountsApi(self.api_client)
|
||||||
|
self.categories_api = ynab.CategoriesApi(self.api_client)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_config(config_path: str | Path) -> YNABConfig:
|
||||||
|
"""Load YNAB configuration from YAML file."""
|
||||||
|
path = Path(config_path)
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
ynab_config = data.get("ynab", [{}])[0]
|
||||||
|
return {
|
||||||
|
"token": ynab_config.get("token", ""),
|
||||||
|
"plan_id": ynab_config.get("plan_id", ""),
|
||||||
|
"accounts": ynab_config.get("accounts", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_cleared_status(cleared: object) -> str:
|
||||||
|
"""Normalize YNAB cleared status values to lowercase strings."""
|
||||||
|
if hasattr(cleared, "value"):
|
||||||
|
return str(getattr(cleared, "value")).strip().lower()
|
||||||
|
|
||||||
|
status = str(cleared).strip()
|
||||||
|
if "." in status:
|
||||||
|
status = status.split(".")[-1]
|
||||||
|
return status.lower()
|
||||||
|
|
||||||
|
def get_transactions_for_days(
|
||||||
|
self,
|
||||||
|
days: int = 30,
|
||||||
|
exclude_cleared_statuses: list[str] | None = None,
|
||||||
|
) -> list[YNABTransaction]:
|
||||||
|
"""
|
||||||
|
Fetch transactions from configured accounts for the past x days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back (default: 30)
|
||||||
|
exclude_cleared_statuses: Optional list of normalized statuses to skip,
|
||||||
|
for example ["reconciled"]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transactions with transformed data
|
||||||
|
"""
|
||||||
|
transactions: list[YNABTransaction] = []
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=days)).date()
|
||||||
|
excluded_statuses = {
|
||||||
|
status.strip().lower() for status in (exclude_cleared_statuses or [])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch all accounts to build name lookup
|
||||||
|
accounts_response = self.accounts_api.get_accounts(self.plan_id)
|
||||||
|
account_map = {
|
||||||
|
str(acc.id): acc.name for acc in accounts_response.data.accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch categories for name lookup
|
||||||
|
categories_response = self.categories_api.get_categories(self.plan_id)
|
||||||
|
category_map = {}
|
||||||
|
for category_group in categories_response.data.category_groups:
|
||||||
|
for cat in category_group.categories:
|
||||||
|
if cat.id:
|
||||||
|
category_map[str(cat.id)] = cat.name
|
||||||
|
|
||||||
|
# If specific accounts are configured, use only those; otherwise use all
|
||||||
|
if self.account_configs:
|
||||||
|
account_ids = [acc["id"] for acc in self.account_configs]
|
||||||
|
else:
|
||||||
|
account_ids = list(account_map.keys())
|
||||||
|
|
||||||
|
# Fetch transactions for each account
|
||||||
|
for account_id in account_ids:
|
||||||
|
account_name = account_map.get(account_id, account_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
txn_response = self.transactions_api.get_transactions_by_account(
|
||||||
|
plan_id=self.plan_id,
|
||||||
|
account_id=account_id,
|
||||||
|
since_date=cutoff_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
for txn in txn_response.data.transactions:
|
||||||
|
txn_date = txn.var_date
|
||||||
|
if txn_date < cutoff_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleared_status = self._normalize_cleared_status(txn.cleared)
|
||||||
|
if cleared_status in excluded_statuses:
|
||||||
|
continue
|
||||||
|
|
||||||
|
category_name = None
|
||||||
|
if txn.category_id:
|
||||||
|
category_name = category_map.get(str(txn.category_id))
|
||||||
|
|
||||||
|
processed: YNABTransaction = {
|
||||||
|
"id": txn.id,
|
||||||
|
"date": str(txn.var_date),
|
||||||
|
"payee_name": txn.payee_name,
|
||||||
|
"category_name": category_name,
|
||||||
|
"amount_milliCurrency": txn.amount,
|
||||||
|
"memo": txn.memo,
|
||||||
|
"account_id": account_id,
|
||||||
|
"account_name": account_name,
|
||||||
|
"cleared": cleared_status,
|
||||||
|
"import_id": txn.import_id,
|
||||||
|
}
|
||||||
|
transactions.append(processed)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching transactions for account {account_name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def get_account_balance(self, account_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Get current balance for an account (in milliCurrency units).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: YNAB account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Account balance in milliCurrency
|
||||||
|
"""
|
||||||
|
accounts_response = self.accounts_api.get_accounts(self.plan_id)
|
||||||
|
for account in accounts_response.data.accounts:
|
||||||
|
if str(account.id) == account_id:
|
||||||
|
return account.balance
|
||||||
|
raise ValueError(f"Account {account_id} not found")
|
||||||
|
|
||||||
|
def get_last_reconciled_date(
|
||||||
|
self,
|
||||||
|
account_ids: list[str] | None = None,
|
||||||
|
search_days: int = 90,
|
||||||
|
) -> datetime | None:
|
||||||
|
"""
|
||||||
|
Find the date of the most recent reconciled transaction across accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_ids: Account IDs to search. If None, uses all configured accounts.
|
||||||
|
search_days: How far back to search for reconciled transactions (default 90).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The date of the last reconciled transaction, or None if none found.
|
||||||
|
"""
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=search_days)).date()
|
||||||
|
|
||||||
|
if account_ids is None:
|
||||||
|
account_ids = [acc["id"] for acc in self.account_configs]
|
||||||
|
|
||||||
|
latest_date: date_type | None = None
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
try:
|
||||||
|
txn_response = self.transactions_api.get_transactions_by_account(
|
||||||
|
plan_id=self.plan_id,
|
||||||
|
account_id=account_id,
|
||||||
|
since_date=cutoff_date,
|
||||||
|
)
|
||||||
|
for txn in txn_response.data.transactions:
|
||||||
|
status = self._normalize_cleared_status(txn.cleared)
|
||||||
|
if status == "reconciled":
|
||||||
|
txn_date = txn.var_date
|
||||||
|
if latest_date is None or txn_date > latest_date:
|
||||||
|
latest_date = txn_date
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error searching reconciled transactions for {account_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return latest_date
|
||||||
|
|
||||||
|
def create_transactions(
|
||||||
|
self,
|
||||||
|
transactions: list[ynab.NewTransaction],
|
||||||
|
) -> ynab.SaveTransactionsResponseData:
|
||||||
|
"""
|
||||||
|
Create multiple transactions in YNAB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transactions: List of NewTransaction objects to create.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SaveTransactionsResponseData with created IDs and duplicate info.
|
||||||
|
"""
|
||||||
|
wrapper = ynab.PostTransactionsWrapper(transactions=transactions)
|
||||||
|
response = self.transactions_api.create_transaction(
|
||||||
|
plan_id=self.plan_id,
|
||||||
|
data=wrapper,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
|
||||||
|
def update_transaction(
|
||||||
|
self,
|
||||||
|
transaction_id: str,
|
||||||
|
cleared: str | None = None,
|
||||||
|
flag_color: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update an existing YNAB transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id: The YNAB transaction ID to update.
|
||||||
|
cleared: New cleared status ('cleared', 'uncleared', 'reconciled').
|
||||||
|
flag_color: New flag color (e.g. 'red', 'blue').
|
||||||
|
"""
|
||||||
|
fields: dict = {}
|
||||||
|
if cleared is not None:
|
||||||
|
fields["cleared"] = ynab.TransactionClearedStatus(cleared)
|
||||||
|
if flag_color is not None:
|
||||||
|
fields["flag_color"] = ynab.TransactionFlagColor(flag_color)
|
||||||
|
|
||||||
|
existing = ynab.ExistingTransaction(**fields)
|
||||||
|
wrapper = ynab.PutTransactionWrapper(transaction=existing)
|
||||||
|
self.transactions_api.update_transaction(
|
||||||
|
plan_id=self.plan_id,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
data=wrapper,
|
||||||
|
)
|
||||||
|
|
||||||
65
ynab_example.py
Normal file
65
ynab_example.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Example usage of YNAB client for fetching transactions."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ynab_client import YNABClient
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Initialize YNAB client from config
|
||||||
|
client = YNABClient("ynab.yml")
|
||||||
|
|
||||||
|
# Fetch transactions from the past 30 days
|
||||||
|
print("Fetching transactions from the past 30 days...")
|
||||||
|
exclude_cleared_statuses = ["reconciled"]
|
||||||
|
transactions = client.get_transactions_for_days(
|
||||||
|
days=30,
|
||||||
|
exclude_cleared_statuses=exclude_cleared_statuses,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Found {len(transactions)} transaction(s) "
|
||||||
|
f"(excluding: {', '.join(exclude_cleared_statuses) or 'none'})\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display first few transactions
|
||||||
|
for i, txn in enumerate(transactions[:10]):
|
||||||
|
print(f"Transaction {i + 1}:")
|
||||||
|
print(f" Date: {txn['date']}")
|
||||||
|
print(f" Payee: {txn['payee_name']}")
|
||||||
|
print(f" Category: {txn['category_name']}")
|
||||||
|
print(f" Amount: ${txn['amount_milliCurrency'] / 1000:.2f}")
|
||||||
|
print(f" Account: {txn['account_name']}")
|
||||||
|
print(f" Memo: {txn['memo']}")
|
||||||
|
print(f" Cleared: {txn['cleared']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Optionally save all transactions to JSON
|
||||||
|
output_file = Path("ynab_transactions.json")
|
||||||
|
serializable = [
|
||||||
|
{
|
||||||
|
"id": txn["id"],
|
||||||
|
"date": txn["date"],
|
||||||
|
"payee_name": txn["payee_name"],
|
||||||
|
"category_name": txn["category_name"],
|
||||||
|
"amount": txn["amount_milliCurrency"] / 1000, # Convert to regular currency
|
||||||
|
"amount_milliCurrency": txn["amount_milliCurrency"],
|
||||||
|
"memo": txn["memo"],
|
||||||
|
"account_id": txn["account_id"],
|
||||||
|
"account_name": txn["account_name"],
|
||||||
|
"cleared": txn["cleared"],
|
||||||
|
}
|
||||||
|
for txn in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
with output_file.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(serializable, f, indent=2)
|
||||||
|
|
||||||
|
print(f"Saved {len(transactions)} YNAB transaction(s) to {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
15
ynab_import.md
Normal file
15
ynab_import.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Overview
|
||||||
|
I need to import extracted transactions into YNAB. This module (`ynab_client.py`) will authenticate with YNAB using the official SDK and retrieve transactions from configured accounts.
|
||||||
|
YNAB API documentation: https://api.youneedabudget.com/v1
|
||||||
|
https://github.com/ynab/ynab-sdk-python/blob/main/docs/TransactionsApi.md#create_transaction
|
||||||
|
https://github.com/ynab/ynab-sdk-python/blob/main/docs/NewTransaction.md
|
||||||
|
|
||||||
|
- I want newly imported transactions to have a flag_color of BLUE
|
||||||
|
- Transactions that are 'posted' should have a cleared status of CLEARED
|
||||||
|
- Transactions that are 'pending' should have a cleared status of UNCLEARED
|
||||||
|
- The YNAB transaction import_id field should be set what is done in https://github.com/ynab/ynab-sdk-python/blob/main/docs/NewTransaction.md
|
||||||
|
- If a posted transaction comes in, and YNAB has an uncleared transaction with the same amount and date, then we should update the existing transaction to be cleared instead of creating a new one. This is to handle the case where a pending transaction becomes posted. We can use the import_id field to match transactions. Note: when a transaction moves from pending to posted, the description may change.
|
||||||
|
- If a transaction is already in YNAB with the same import_id and is cleared, we should still import the new transaction, but give it a flag_status of RED.
|
||||||
|
- Before importing, we should show the user in a presentable way the new transactions, the pending to posted matched transactions, and the possible duplicates (RED). Ask the user if they would like to import.
|
||||||
|
|
||||||
|
This process should happen when the user runs with a parameter like '--ynab-import'
|
||||||
Reference in New Issue
Block a user