diff --git a/CLAUDE.md b/CLAUDE.md index 7fc0087..38deccd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -369,6 +369,7 @@ If you need to adjust the bot's behavior: - ✅ AI can create commits locally - ❌ AI must NEVER push to git without explicit user permission - ❌ AI must NEVER create new branches unless explicitly asked by the user +- ❌ AI must NEVER amend commits unless explicitly asked by the user - 💡 User should review commits before pushing ```bash diff --git a/docs/guide/amazon-mode.md b/docs/guide/amazon-mode.md index d5600aa..d583608 100644 --- a/docs/guide/amazon-mode.md +++ b/docs/guide/amazon-mode.md @@ -137,6 +137,45 @@ moneyflow amazon import ~/Downloads/"Your Orders" Cancelled orders are automatically skipped during import. +### Transaction Linking with Monarch/YNAB + +When you use Amazon mode alongside Monarch Money or YNAB, moneyflow can automatically link Amazon orders to +transactions in your bank accounts. + +**How it works:** + +When viewing a transaction in Monarch or YNAB that has an Amazon-like merchant name (e.g., "Amazon.com", +"AMZN MKTP US"), pressing ++i++ (Info) will: + +1. Search your Amazon database for matching orders +2. Match by amount (within $0.02 tolerance) and date (within 7 days) +3. Display matched orders at the top of the transaction details + +**Example:** + +You have a $47.98 charge from "AMZN MKTP US" on your credit card. Pressing ++i++ shows: + +```text +Matching Amazon Orders +─────────────────────────────────────── +Order: 113-1234567-8901234* +Date: 2025-01-10 | From: amazon + USB-C Cable (x2): -$12.99 + Wireless Mouse: -$24.99 + Total: -$37.98 +─────────────────────────────────────── +``` + +The `*` indicates a high-confidence match (exact amount and close date). + +**Requirements:** + +- Import your Amazon purchase history first (`moneyflow amazon import`) +- Transaction must have "amazon" or "amzn" in the merchant name +- Amount and date must be within tolerance + +This feature helps you identify exactly what items were in each Amazon charge, making categorization easier. + ### Incremental Imports Amazon mode supports incremental imports, preserving any manual edits you've made: diff --git a/moneyflow/amazon_linker.py b/moneyflow/amazon_linker.py new file mode 100644 index 0000000..3ed00b5 --- /dev/null +++ b/moneyflow/amazon_linker.py @@ -0,0 +1,307 @@ +""" +Amazon transaction linker service. + +Links Monarch/YNAB transactions to Amazon orders by matching amount and date. +Searches Amazon profile databases for orders that match a given transaction. +""" + +import sqlite3 +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import List, Optional + +from .logging_config import get_logger + +logger = get_logger(__name__) + + +@dataclass +class AmazonOrderMatch: + """ + Represents a matched Amazon order. + + Attributes: + order_id: Amazon order ID (e.g., "113-1234567-8901234") + order_date: Date of the order (YYYY-MM-DD format) + total_amount: Sum of all items in the order (negative for expenses) + items: List of items in the order, each with: + - name: Product name + - amount: Item price (negative) + - quantity: Number of items + - asin: Amazon Standard Identification Number + confidence: Match confidence ("high" for exact match, "medium" for close) + source_profile: Name of the Amazon profile this match came from + """ + + order_id: str + order_date: str + total_amount: float + items: List[dict] + confidence: str + source_profile: str + + +class AmazonLinker: + """ + Links transactions to Amazon orders by matching amount and date. + + This service searches Amazon profile databases for orders that match + a given transaction amount (within tolerance) and date (within date range). + + Usage: + linker = AmazonLinker(config_dir=Path.home() / ".moneyflow") + + # Check if merchant looks like Amazon + if linker.is_amazon_merchant("AMZN MKTP US"): + matches = linker.find_matching_orders( + amount=-37.98, + transaction_date="2025-01-15", + ) + for match in matches: + print(f"Order {match.order_id}: {match.total_amount}") + """ + + # Patterns that identify Amazon merchants (case-insensitive) + AMAZON_PATTERNS = ["amazon", "amzn"] + + # Default amount tolerance (allows for rounding differences) + AMOUNT_TOLERANCE = 0.02 + + def __init__(self, config_dir: Path): + """ + Initialize the Amazon linker. + + Args: + config_dir: Path to moneyflow config directory (e.g., ~/.moneyflow) + """ + self.config_dir = Path(config_dir) + self.profiles_dir = self.config_dir / "profiles" + + def is_amazon_merchant(self, merchant_name: str) -> bool: + """ + Check if a merchant name looks like Amazon. + + Args: + merchant_name: Merchant name from transaction + + Returns: + True if merchant appears to be Amazon + """ + if not merchant_name: + return False + + merchant_lower = merchant_name.lower() + return any(pattern in merchant_lower for pattern in self.AMAZON_PATTERNS) + + def find_amazon_databases(self) -> List[Path]: + """ + Find all Amazon profile databases. + + Searches for amazon.db files in profiles named "amazon" or starting with "amazon-". + + Returns: + List of paths to Amazon databases + """ + databases = [] + + if not self.profiles_dir.exists(): + return databases + + for profile_dir in self.profiles_dir.iterdir(): + if not profile_dir.is_dir(): + continue + + # Look in profiles named "amazon" or starting with "amazon-" + profile_name = profile_dir.name + if profile_name != "amazon" and not profile_name.startswith("amazon-"): + continue + + db_path = profile_dir / "amazon.db" + if db_path.exists(): + databases.append(db_path) + + return databases + + def find_matching_orders( + self, + amount: float, + transaction_date: str, + date_tolerance_days: int = 7, + amount_tolerance: Optional[float] = None, + ) -> List[AmazonOrderMatch]: + """ + Find Amazon orders matching the given amount and date. + + Args: + amount: Transaction amount to match (negative for expenses) + transaction_date: Transaction date (YYYY-MM-DD format) + date_tolerance_days: Days +/- to search for matching orders + amount_tolerance: Amount tolerance for matching (default 0.02) + + Returns: + List of matching orders, sorted by date proximity (closest first) + """ + if amount_tolerance is None: + amount_tolerance = self.AMOUNT_TOLERANCE + + databases = self.find_amazon_databases() + if not databases: + return [] + + # Parse transaction date + try: + txn_date = datetime.strptime(transaction_date, "%Y-%m-%d").date() + except ValueError: + logger.warning(f"Invalid transaction date format: {transaction_date}") + return [] + + # Calculate date range + start_date = txn_date - timedelta(days=date_tolerance_days) + end_date = txn_date + timedelta(days=date_tolerance_days) + + all_matches: List[AmazonOrderMatch] = [] + + for db_path in databases: + try: + matches = self._search_database( + db_path=db_path, + amount=amount, + amount_tolerance=amount_tolerance, + start_date=start_date, + end_date=end_date, + txn_date=txn_date, + ) + all_matches.extend(matches) + except Exception as e: + logger.warning(f"Error searching database {db_path}: {e}") + continue + + # Sort by date proximity (closest to transaction date first) + all_matches.sort(key=lambda m: abs((self._parse_date(m.order_date) - txn_date).days)) + + return all_matches + + def _parse_date(self, date_str: str) -> date: + """Parse a date string to date object.""" + return datetime.strptime(date_str, "%Y-%m-%d").date() + + def _search_database( + self, + db_path: Path, + amount: float, + amount_tolerance: float, + start_date: date, + end_date: date, + txn_date: date, + ) -> List[AmazonOrderMatch]: + """ + Search a single Amazon database for matching orders. + + Args: + db_path: Path to Amazon database + amount: Amount to match + amount_tolerance: Amount tolerance + start_date: Start of date range + end_date: End of date range + txn_date: Transaction date (for confidence calculation) + + Returns: + List of matching orders from this database + """ + matches = [] + profile_name = db_path.parent.name + + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + # Query to aggregate orders and filter by date range + # We'll do amount filtering in Python for flexibility + query = """ + SELECT + order_id, + MIN(date) as order_date, + SUM(amount) as total_amount, + GROUP_CONCAT(id) as item_ids + FROM transactions + WHERE date >= ? AND date <= ? + GROUP BY order_id + """ + + cursor = conn.execute( + query, + (start_date.isoformat(), end_date.isoformat()), + ) + + for row in cursor: + order_total = row["total_amount"] + + # Check amount match within tolerance + if abs(order_total - amount) > amount_tolerance: + continue + + # Fetch item details for this order + items = self._fetch_order_items(conn, row["order_id"]) + + # Determine confidence + order_date = self._parse_date(row["order_date"]) + days_diff = abs((order_date - txn_date).days) + amount_diff = abs(order_total - amount) + + if amount_diff < 0.01 and days_diff <= 2: + confidence = "high" + else: + confidence = "medium" + + match = AmazonOrderMatch( + order_id=row["order_id"], + order_date=row["order_date"], + total_amount=round(order_total, 2), + items=items, + confidence=confidence, + source_profile=profile_name, + ) + matches.append(match) + + conn.close() + + except sqlite3.DatabaseError as e: + logger.warning(f"Database error reading {db_path}: {e}") + return [] + + return matches + + def _fetch_order_items(self, conn: sqlite3.Connection, order_id: str) -> List[dict]: + """ + Fetch all items for a given order. + + Args: + conn: Database connection + order_id: Order ID to fetch items for + + Returns: + List of item dicts with name, amount, quantity, asin + """ + cursor = conn.execute( + """ + SELECT merchant as name, amount, quantity, asin + FROM transactions + WHERE order_id = ? + ORDER BY merchant + """, + (order_id,), + ) + + items = [] + for row in cursor: + items.append( + { + "name": row["name"], + "amount": row["amount"], + "quantity": row["quantity"], + "asin": row["asin"], + } + ) + + return items diff --git a/moneyflow/app.py b/moneyflow/app.py index d4231cb..3de04eb 100644 --- a/moneyflow/app.py +++ b/moneyflow/app.py @@ -340,8 +340,9 @@ def _initialize_managers( backend_type=backend_type, ) - # Initialize cache manager (with encryption if key available) - if self.cache_path is not None: + # Initialize cache manager (only if encryption key available) + # Backends like Amazon don't have encryption keys and don't need caching + if self.cache_path is not None and self.encryption_key is not None: # Determine cache directory if self.cache_path == "": # Default cache location - use profile-specific or legacy location @@ -1767,9 +1768,63 @@ def action_show_transaction_details(self) -> None: # Get current transaction data row_data = self.state.current_data.row(table.cursor_row, named=True) + transaction_dict = dict(row_data) - # Show detail modal (doesn't change view state, just displays info) - self.push_screen(TransactionDetailScreen(dict(row_data))) + # Look for matching Amazon orders if this looks like an Amazon transaction + amazon_matches, amazon_searched = self._find_amazon_matches(transaction_dict) + + # Show detail modal with any Amazon matches + self.push_screen( + TransactionDetailScreen( + transaction_dict, + amazon_matches=amazon_matches, + amazon_searched=amazon_searched, + ) + ) + + def _find_amazon_matches(self, transaction: dict) -> tuple[list, bool]: + """ + Find matching Amazon orders for a transaction. + + Args: + transaction: Transaction dict with merchant, amount, date fields + + Returns: + Tuple of (matches, searched) where: + - matches: List of AmazonOrderMatch objects + - searched: True if we searched (merchant looked like Amazon) + """ + from .amazon_linker import AmazonLinker + + logger = get_logger(__name__) + merchant = transaction.get("merchant", "") + amount = transaction.get("amount", 0) + txn_date = transaction.get("date", "") + + # Convert date to string if it's a date object + if hasattr(txn_date, "isoformat"): + txn_date = txn_date.isoformat() + else: + txn_date = str(txn_date) + + # Initialize linker with config directory + config_dir = Path.home() / ".moneyflow" + linker = AmazonLinker(config_dir) + + # Only look up if merchant looks like Amazon + if not linker.is_amazon_merchant(merchant): + return [], False + + try: + matches = linker.find_matching_orders( + amount=float(amount), + transaction_date=txn_date, + date_tolerance_days=7, + ) + return matches, True + except Exception as e: + logger.warning(f"Error finding Amazon matches: {e}") + return [], True def action_delete_transaction(self) -> None: """Delete current transaction with confirmation.""" diff --git a/moneyflow/screens/transaction_detail_screen.py b/moneyflow/screens/transaction_detail_screen.py index 12e88df..ddf0bdb 100644 --- a/moneyflow/screens/transaction_detail_screen.py +++ b/moneyflow/screens/transaction_detail_screen.py @@ -1,5 +1,7 @@ """Transaction detail view screen.""" +from typing import List, Optional + from textual.app import ComposeResult from textual.containers import Container, VerticalScroll from textual.events import Key @@ -20,7 +22,7 @@ class TransactionDetailScreen(ModalScreen): #detail-dialog { width: 80; height: auto; - max-height: 40; + max-height: 50; border: thick $primary; background: $surface; padding: 2 4; @@ -35,7 +37,7 @@ class TransactionDetailScreen(ModalScreen): } #detail-content { - height: 30; + height: 40; border: solid $panel; padding: 1; } @@ -58,17 +60,64 @@ class TransactionDetailScreen(ModalScreen): margin-top: 1; text-style: italic; } + + .amazon-section-title { + color: $warning; + text-style: bold; + margin-bottom: 1; + padding: 0 0 1 0; + border-bottom: solid $panel; + } + + .amazon-no-match { + color: $text-muted; + text-style: italic; + margin-bottom: 2; + } + + .amazon-order-header { + color: $accent; + margin-top: 1; + } + + .amazon-order-info { + color: $text-muted; + margin-left: 2; + } + + .amazon-item { + color: $text; + margin-left: 4; + } + + .amazon-total { + color: $text; + text-style: bold; + margin-left: 2; + margin-top: 1; + } """ - def __init__(self, transaction_data: dict): + def __init__( + self, + transaction_data: dict, + amazon_matches: Optional[List] = None, + amazon_searched: bool = False, + ): super().__init__() self.transaction_data = transaction_data + self.amazon_matches = amazon_matches or [] + self.amazon_searched = amazon_searched def compose(self) -> ComposeResult: with Container(id="detail-dialog"): - yield Label("📋 Transaction Details", id="detail-title") + yield Label("Transaction Details", id="detail-title") with VerticalScroll(id="detail-content"): + # Amazon matches section (at top if searched) + if self.amazon_searched: + yield from self._compose_amazon_section() + # Core fields yield Label("ID:", classes="field-label") yield Static(str(self.transaction_data.get("id", "N/A")), classes="field-value") @@ -132,6 +181,39 @@ def compose(self) -> ComposeResult: yield Static("Esc/Enter=Close", id="close-hint") + def _compose_amazon_section(self) -> ComposeResult: + """Compose the Amazon matches section.""" + yield Label("Matching Amazon Orders", classes="amazon-section-title") + + if not self.amazon_matches: + yield Static("No matching orders found", classes="amazon-no-match") + return + + for match in self.amazon_matches: + # Order header + confidence_marker = "*" if match.confidence == "high" else "" + yield Label( + f"Order: {match.order_id}{confidence_marker}", + classes="amazon-order-header", + ) + yield Static( + f"Date: {match.order_date} | From: {match.source_profile}", + classes="amazon-order-info", + ) + + # Items + for item in match.items: + qty_str = f" (x{item['quantity']})" if item["quantity"] > 1 else "" + amount_str = ViewPresenter.format_amount(item["amount"]) + yield Static( + f" {item['name']}{qty_str}: {amount_str}", + classes="amazon-item", + ) + + # Total + total_str = ViewPresenter.format_amount(match.total_amount) + yield Static(f"Total: {total_str}", classes="amazon-total") + def on_key(self, event: Key) -> None: """Handle keyboard shortcuts.""" if event.key in ("escape", "enter"): diff --git a/tests/test_amazon_linker.py b/tests/test_amazon_linker.py new file mode 100644 index 0000000..a1b694b --- /dev/null +++ b/tests/test_amazon_linker.py @@ -0,0 +1,741 @@ +"""Tests for Amazon transaction linker service.""" + +import sqlite3 +from pathlib import Path + +import pytest + +from moneyflow.amazon_linker import AmazonLinker, AmazonOrderMatch + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """Create a temporary config directory structure.""" + profiles_dir = tmp_path / "profiles" + profiles_dir.mkdir() + return tmp_path + + +@pytest.fixture +def amazon_profile(config_dir: Path) -> Path: + """Create an amazon profile with a database.""" + profile_dir = config_dir / "profiles" / "amazon-orders" + profile_dir.mkdir(parents=True) + return profile_dir + + +def create_amazon_db(profile_dir: Path, orders: list[dict]) -> Path: + """ + Create an Amazon database with test orders. + + Args: + profile_dir: Profile directory to create db in + orders: List of order dicts with keys: + - order_id: str + - date: str (YYYY-MM-DD) + - items: list of dicts with {name, amount, quantity, asin} + + Returns: + Path to created database + """ + db_path = profile_dir / "amazon.db" + conn = sqlite3.connect(db_path) + + # Create schema matching AmazonBackend + conn.execute(""" + CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + merchant TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Uncategorized', + category_id TEXT NOT NULL DEFAULT 'cat_uncategorized', + amount REAL NOT NULL, + quantity INTEGER NOT NULL, + asin TEXT NOT NULL, + order_id TEXT NOT NULL, + account TEXT NOT NULL, + order_status TEXT, + shipment_status TEXT, + notes TEXT, + hideFromReports INTEGER DEFAULT 0, + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Insert test data + for order in orders: + order_id = order["order_id"] + order_date = order["date"] + for item in order["items"]: + # Generate deterministic ID like AmazonBackend does + clean_order = order_id.replace("-", "").replace(" ", "") + txn_id = f"amz_{item['asin']}_{clean_order}" + + conn.execute( + """ + INSERT INTO transactions + (id, date, merchant, amount, quantity, asin, order_id, account, order_status, shipment_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + txn_id, + order_date, + item["name"], + item["amount"], # Should be negative + item["quantity"], + item["asin"], + order_id, + order_id, + "Closed", + "Delivered", + ), + ) + + conn.commit() + conn.close() + return db_path + + +class TestAmazonLinkerFindDatabases: + """Tests for finding Amazon databases.""" + + def test_find_no_databases(self, config_dir: Path) -> None: + """Should return empty list when no Amazon profiles exist.""" + linker = AmazonLinker(config_dir) + assert linker.find_amazon_databases() == [] + + def test_find_single_database(self, config_dir: Path, amazon_profile: Path) -> None: + """Should find database in amazon profile.""" + # Create empty database + db_path = amazon_profile / "amazon.db" + conn = sqlite3.connect(db_path) + conn.close() + + linker = AmazonLinker(config_dir) + databases = linker.find_amazon_databases() + + assert len(databases) == 1 + assert databases[0] == db_path + + def test_find_database_in_amazon_profile_without_dash(self, config_dir: Path) -> None: + """Should find database in profile named exactly 'amazon' (no dash suffix).""" + profiles_dir = config_dir / "profiles" + profile_dir = profiles_dir / "amazon" + profile_dir.mkdir(parents=True) + + db_path = profile_dir / "amazon.db" + conn = sqlite3.connect(db_path) + conn.close() + + linker = AmazonLinker(config_dir) + databases = linker.find_amazon_databases() + + assert len(databases) == 1 + assert databases[0] == db_path + + def test_find_multiple_databases(self, config_dir: Path) -> None: + """Should find databases in multiple amazon profiles.""" + profiles_dir = config_dir / "profiles" + + # Create two Amazon profiles + for name in ["amazon-orders", "amazon-wife"]: + profile_dir = profiles_dir / name + profile_dir.mkdir(parents=True) + db_path = profile_dir / "amazon.db" + conn = sqlite3.connect(db_path) + conn.close() + + linker = AmazonLinker(config_dir) + databases = linker.find_amazon_databases() + + assert len(databases) == 2 + + def test_ignore_non_amazon_profiles(self, config_dir: Path) -> None: + """Should not find databases in non-amazon profiles.""" + profiles_dir = config_dir / "profiles" + + # Create non-amazon profile with a database + monarch_profile = profiles_dir / "monarch-personal" + monarch_profile.mkdir(parents=True) + (monarch_profile / "amazon.db").touch() + + linker = AmazonLinker(config_dir) + databases = linker.find_amazon_databases() + + assert len(databases) == 0 + + def test_skip_profile_without_database(self, config_dir: Path) -> None: + """Should skip amazon profiles without amazon.db.""" + profiles_dir = config_dir / "profiles" + + # Create amazon profile without database + profile_dir = profiles_dir / "amazon-empty" + profile_dir.mkdir(parents=True) + + linker = AmazonLinker(config_dir) + databases = linker.find_amazon_databases() + + assert len(databases) == 0 + + +class TestAmazonLinkerMatching: + """Tests for matching Amazon orders to transactions.""" + + def test_exact_amount_match(self, config_dir: Path, amazon_profile: Path) -> None: + """Should match order with exact amount.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="2025-01-10", + ) + + assert len(matches) == 1 + assert matches[0].order_id == "113-1234567-8901234" + assert matches[0].total_amount == -12.99 + assert len(matches[0].items) == 1 + assert matches[0].items[0]["name"] == "USB Cable" + + def test_multi_item_order_sum(self, config_dir: Path, amazon_profile: Path) -> None: + """Should sum multiple items in same order.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + {"name": "Mouse", "amount": -24.99, "quantity": 1, "asin": "B002"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-37.98, # Sum of both items + transaction_date="2025-01-10", + ) + + assert len(matches) == 1 + assert matches[0].order_id == "113-1234567-8901234" + assert abs(matches[0].total_amount - (-37.98)) < 0.01 + assert len(matches[0].items) == 2 + + def test_date_tolerance_within_range(self, config_dir: Path, amazon_profile: Path) -> None: + """Should match orders within date tolerance.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # Transaction 5 days later should match (within 7 day tolerance) + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="2025-01-15", + date_tolerance_days=7, + ) + + assert len(matches) == 1 + + def test_date_tolerance_outside_range(self, config_dir: Path, amazon_profile: Path) -> None: + """Should not match orders outside date tolerance.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # Transaction 10 days later should NOT match (outside 7 day tolerance) + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="2025-01-20", + date_tolerance_days=7, + ) + + assert len(matches) == 0 + + def test_amount_tolerance(self, config_dir: Path, amazon_profile: Path) -> None: + """Should match amounts within penny tolerance.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # Slightly different amount should still match (within penny tolerance) + matches = linker.find_matching_orders( + amount=-12.98, # Off by 1 cent + transaction_date="2025-01-10", + ) + + assert len(matches) == 1 + + def test_no_match_wrong_amount(self, config_dir: Path, amazon_profile: Path) -> None: + """Should not match orders with different amounts.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-50.00, # Different amount + transaction_date="2025-01-10", + ) + + assert len(matches) == 0 + + def test_multiple_orders_match(self, config_dir: Path, amazon_profile: Path) -> None: + """Should return multiple matching orders if they exist.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1111111-1111111", + "date": "2025-01-10", + "items": [ + {"name": "Item A", "amount": -25.00, "quantity": 1, "asin": "A001"}, + ], + }, + { + "order_id": "113-2222222-2222222", + "date": "2025-01-12", + "items": [ + {"name": "Item B", "amount": -25.00, "quantity": 1, "asin": "B001"}, + ], + }, + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-25.00, + transaction_date="2025-01-11", + ) + + assert len(matches) == 2 + + def test_matches_sorted_by_date_proximity(self, config_dir: Path, amazon_profile: Path) -> None: + """Should sort matches by date proximity (closest first).""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-FAR-1111111", + "date": "2025-01-05", # 6 days before transaction + "items": [ + {"name": "Item Far", "amount": -25.00, "quantity": 1, "asin": "A001"}, + ], + }, + { + "order_id": "113-CLOSE-2222222", + "date": "2025-01-10", # 1 day before transaction + "items": [ + {"name": "Item Close", "amount": -25.00, "quantity": 1, "asin": "B001"}, + ], + }, + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-25.00, + transaction_date="2025-01-11", + ) + + assert len(matches) == 2 + # Closest date should be first + assert matches[0].order_id == "113-CLOSE-2222222" + assert matches[1].order_id == "113-FAR-1111111" + + def test_search_multiple_databases(self, config_dir: Path) -> None: + """Should search across all Amazon profile databases.""" + profiles_dir = config_dir / "profiles" + + # Create first amazon profile with order + profile1 = profiles_dir / "amazon-personal" + profile1.mkdir(parents=True) + create_amazon_db( + profile1, + [ + { + "order_id": "113-1111111-1111111", + "date": "2025-01-10", + "items": [ + {"name": "Item 1", "amount": -30.00, "quantity": 1, "asin": "A001"}, + ], + } + ], + ) + + # Create second amazon profile with different order + profile2 = profiles_dir / "amazon-wife" + profile2.mkdir(parents=True) + create_amazon_db( + profile2, + [ + { + "order_id": "113-2222222-2222222", + "date": "2025-01-10", + "items": [ + {"name": "Item 2", "amount": -30.00, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-30.00, + transaction_date="2025-01-10", + ) + + # Should find orders from both databases + assert len(matches) == 2 + + def test_empty_database(self, config_dir: Path, amazon_profile: Path) -> None: + """Should handle empty database gracefully.""" + # Create empty database (no orders) + create_amazon_db(amazon_profile, []) + + linker = AmazonLinker(config_dir) + matches = linker.find_matching_orders( + amount=-25.00, + transaction_date="2025-01-10", + ) + + assert len(matches) == 0 + + def test_corrupted_database(self, config_dir: Path, amazon_profile: Path) -> None: + """Should handle corrupted database gracefully.""" + # Create invalid database file + db_path = amazon_profile / "amazon.db" + db_path.write_text("not a sqlite database") + + linker = AmazonLinker(config_dir) + # Should not raise, just return empty list + matches = linker.find_matching_orders( + amount=-25.00, + transaction_date="2025-01-10", + ) + + assert len(matches) == 0 + + +class TestAmazonOrderMatch: + """Tests for AmazonOrderMatch dataclass.""" + + def test_order_match_creation(self) -> None: + """Should create AmazonOrderMatch with all fields.""" + match = AmazonOrderMatch( + order_id="113-1234567-8901234", + order_date="2025-01-10", + total_amount=-37.98, + items=[ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + {"name": "Mouse", "amount": -24.99, "quantity": 1, "asin": "B002"}, + ], + confidence="high", + source_profile="amazon-orders", + ) + + assert match.order_id == "113-1234567-8901234" + assert match.order_date == "2025-01-10" + assert match.total_amount == -37.98 + assert len(match.items) == 2 + assert match.confidence == "high" + assert match.source_profile == "amazon-orders" + + +class TestIsAmazonMerchant: + """Tests for Amazon merchant name detection.""" + + def test_amazon_variations(self, config_dir: Path) -> None: + """Should detect various Amazon merchant name patterns.""" + linker = AmazonLinker(config_dir) + + # Should match + assert linker.is_amazon_merchant("Amazon.com") is True + assert linker.is_amazon_merchant("AMAZON.COM") is True + assert linker.is_amazon_merchant("Amazon") is True + assert linker.is_amazon_merchant("AMZN Mktp US") is True + assert linker.is_amazon_merchant("AMZN MKTP US*MK1234") is True + assert linker.is_amazon_merchant("Amazon.com*AB1234") is True + assert linker.is_amazon_merchant("AMAZON PRIME") is True + assert linker.is_amazon_merchant("Amazon Fresh") is True + + # Should not match + assert linker.is_amazon_merchant("Walmart") is False + assert linker.is_amazon_merchant("Best Buy") is False + assert linker.is_amazon_merchant("Target") is False + + def test_empty_and_none_merchant(self, config_dir: Path) -> None: + """Should handle empty and None merchant names gracefully.""" + linker = AmazonLinker(config_dir) + + assert linker.is_amazon_merchant("") is False + assert linker.is_amazon_merchant(None) is False # type: ignore + + +class TestEdgeCases: + """Test edge cases and potential failure points.""" + + def test_invalid_date_format(self, config_dir: Path, amazon_profile: Path) -> None: + """Should handle invalid date format gracefully.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # Invalid date format should return empty list, not crash + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="invalid-date", + ) + assert matches == [] + + def test_date_object_conversion(self, config_dir: Path, amazon_profile: Path) -> None: + """Should work when date is passed as various formats.""" + + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # String date should work + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="2025-01-10", + ) + assert len(matches) == 1 + + def test_positive_amount_no_match(self, config_dir: Path, amazon_profile: Path) -> None: + """Positive amounts should not match negative Amazon orders.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + {"name": "USB Cable", "amount": -12.99, "quantity": 1, "asin": "B001"}, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + # Positive amount (different sign) should not match + matches = linker.find_matching_orders( + amount=12.99, # Positive, not negative + transaction_date="2025-01-10", + ) + assert len(matches) == 0 + + def test_zero_amount(self, config_dir: Path, amazon_profile: Path) -> None: + """Should handle zero amounts without crashing.""" + create_amazon_db(amazon_profile, []) + + linker = AmazonLinker(config_dir) + + # Zero amount should not crash + matches = linker.find_matching_orders( + amount=0.0, + transaction_date="2025-01-10", + ) + assert matches == [] + + def test_very_large_amount(self, config_dir: Path, amazon_profile: Path) -> None: + """Should handle very large amounts without issues.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + { + "name": "Expensive Item", + "amount": -9999.99, + "quantity": 1, + "asin": "B001", + }, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + matches = linker.find_matching_orders( + amount=-9999.99, + transaction_date="2025-01-10", + ) + assert len(matches) == 1 + + def test_special_characters_in_product_name( + self, config_dir: Path, amazon_profile: Path + ) -> None: + """Should handle special characters in product names.""" + create_amazon_db( + amazon_profile, + [ + { + "order_id": "113-1234567-8901234", + "date": "2025-01-10", + "items": [ + { + "name": "USB-C Cable (3-Pack) - 6ft & 10ft", + "amount": -12.99, + "quantity": 1, + "asin": "B001", + }, + ], + } + ], + ) + + linker = AmazonLinker(config_dir) + + matches = linker.find_matching_orders( + amount=-12.99, + transaction_date="2025-01-10", + ) + assert len(matches) == 1 + assert matches[0].items[0]["name"] == "USB-C Cable (3-Pack) - 6ft & 10ft" + + def test_profiles_dir_does_not_exist(self, tmp_path: Path) -> None: + """Should handle missing profiles directory gracefully.""" + # Config dir exists but no profiles subdirectory + config_dir = tmp_path / "empty_config" + config_dir.mkdir() + + linker = AmazonLinker(config_dir) + + # Should not crash, just return empty + databases = linker.find_amazon_databases() + assert databases == [] + + matches = linker.find_matching_orders( + amount=-25.00, + transaction_date="2025-01-10", + ) + assert matches == [] + + +class TestTransactionDetailScreenWithAmazon: + """Test TransactionDetailScreen with Amazon matches.""" + + def test_screen_initializes_with_matches(self) -> None: + """Screen should accept amazon_matches parameter.""" + from moneyflow.screens.transaction_detail_screen import TransactionDetailScreen + + transaction = {"id": "txn_1", "date": "2025-01-10", "amount": -25.00, "merchant": "Amazon"} + matches = [ + AmazonOrderMatch( + order_id="113-1234567-8901234", + order_date="2025-01-10", + total_amount=-25.00, + items=[{"name": "Item", "amount": -25.00, "quantity": 1, "asin": "B001"}], + confidence="high", + source_profile="amazon", + ) + ] + + screen = TransactionDetailScreen(transaction, amazon_matches=matches, amazon_searched=True) + + assert screen.amazon_matches == matches + assert screen.amazon_searched is True + + def test_screen_initializes_without_matches(self) -> None: + """Screen should work with no amazon_matches.""" + from moneyflow.screens.transaction_detail_screen import TransactionDetailScreen + + transaction = {"id": "txn_1", "date": "2025-01-10", "amount": -25.00, "merchant": "Walmart"} + + screen = TransactionDetailScreen(transaction) + + assert screen.amazon_matches == [] + assert screen.amazon_searched is False + + def test_screen_searched_but_no_matches(self) -> None: + """Screen should handle searched=True with empty matches.""" + from moneyflow.screens.transaction_detail_screen import TransactionDetailScreen + + transaction = {"id": "txn_1", "date": "2025-01-10", "amount": -25.00, "merchant": "Amazon"} + + screen = TransactionDetailScreen(transaction, amazon_matches=[], amazon_searched=True) + + assert screen.amazon_matches == [] + assert screen.amazon_searched is True diff --git a/tests/test_amazon_orders_importer.py b/tests/test_amazon_orders_importer.py index 1132f92..76fc6c1 100644 --- a/tests/test_amazon_orders_importer.py +++ b/tests/test_amazon_orders_importer.py @@ -547,3 +547,87 @@ async def test_fetch_respects_date_filters(self, sample_orders_csv, temp_db, tem for txn in transactions: assert txn["date"] >= "2025-10-12" assert txn["date"] <= "2025-10-13" + + +class TestAmazonNoEncryption: + """Test that Amazon mode works without encryption (no cache manager). + + Amazon backend stores data locally in SQLite and doesn't need: + - Credentials (no login required) + - Encryption key (data is local, not sensitive API tokens) + - Cache manager (data is already local) + + These tests ensure we don't regress and accidentally require encryption + for Amazon mode. + """ + + def test_amazon_backend_works_without_encryption_key(self, temp_db, temp_config_dir): + """Amazon backend should initialize without any encryption key.""" + # This should not raise any errors + backend = AmazonBackend(temp_db, config_dir=temp_config_dir) + assert backend is not None + + @pytest.mark.asyncio + async def test_amazon_fetch_without_encryption( + self, sample_orders_csv, temp_db, temp_config_dir + ): + """Amazon backend should fetch data without encryption key.""" + backend = AmazonBackend(temp_db, config_dir=temp_config_dir) + import_amazon_orders(str(sample_orders_csv), backend) + + # Fetch should work without any encryption + result = await backend.get_transactions() + transactions = result["allTransactions"]["results"] + + assert len(transactions) == 3 + + def test_cache_manager_not_created_without_encryption_key(self, temp_config_dir): + """CacheManager should not be created when encryption_key is None.""" + from moneyflow.cache_manager import CacheManager + + # When encryption_key is None, CacheManager can be created but + # save/load operations should be skipped or raise clear errors + cache_mgr = CacheManager(cache_dir=temp_config_dir, encryption_key=None) + + # The manager exists but has no fernet cipher + assert cache_mgr.fernet is None + assert cache_mgr.encryption_key is None + + def test_cache_manager_save_raises_without_encryption( + self, temp_config_dir, sample_orders_csv, temp_db + ): + """CacheManager.save_cache should raise ValueError without encryption key.""" + import polars as pl + import pytest + + from moneyflow.cache_manager import CacheManager + + cache_mgr = CacheManager(cache_dir=temp_config_dir, encryption_key=None) + + # Create minimal test data + df = pl.DataFrame({"id": ["1"], "date": ["2025-01-01"], "amount": [10.0]}) + categories = {"cat_1": "Category 1"} + category_groups = {} + + # Should raise ValueError, not crash with unclear error + with pytest.raises(ValueError, match="encryption key not set"): + cache_mgr.save_cache(df, categories, category_groups) + + @pytest.mark.asyncio + async def test_data_manager_works_without_cache( + self, sample_orders_csv, temp_db, temp_config_dir + ): + """DataManager should work with Amazon backend and no cache manager.""" + from moneyflow.data_manager import DataManager + + backend = AmazonBackend(temp_db, config_dir=temp_config_dir) + import_amazon_orders(str(sample_orders_csv), backend) + + # DataManager with cache_manager=None should work fine + data_manager = DataManager(backend, config_dir=temp_config_dir) + df, categories, category_groups = await data_manager.fetch_all_data() + + # Data should load successfully + assert df is not None + assert len(df) == 3 + assert categories is not None