|
| 1 | +""" |
| 2 | +Amazon transaction linker service. |
| 3 | +
|
| 4 | +Links Monarch/YNAB transactions to Amazon orders by matching amount and date. |
| 5 | +Searches Amazon profile databases for orders that match a given transaction. |
| 6 | +""" |
| 7 | + |
| 8 | +import sqlite3 |
| 9 | +from dataclasses import dataclass |
| 10 | +from datetime import date, datetime, timedelta |
| 11 | +from pathlib import Path |
| 12 | +from typing import List, Optional |
| 13 | + |
| 14 | +from .logging_config import get_logger |
| 15 | + |
| 16 | +logger = get_logger(__name__) |
| 17 | + |
| 18 | + |
| 19 | +@dataclass |
| 20 | +class AmazonOrderMatch: |
| 21 | + """ |
| 22 | + Represents a matched Amazon order. |
| 23 | +
|
| 24 | + Attributes: |
| 25 | + order_id: Amazon order ID (e.g., "113-1234567-8901234") |
| 26 | + order_date: Date of the order (YYYY-MM-DD format) |
| 27 | + total_amount: Sum of all items in the order (negative for expenses) |
| 28 | + items: List of items in the order, each with: |
| 29 | + - name: Product name |
| 30 | + - amount: Item price (negative) |
| 31 | + - quantity: Number of items |
| 32 | + - asin: Amazon Standard Identification Number |
| 33 | + confidence: Match confidence ("high" for exact match, "medium" for close) |
| 34 | + source_profile: Name of the Amazon profile this match came from |
| 35 | + """ |
| 36 | + |
| 37 | + order_id: str |
| 38 | + order_date: str |
| 39 | + total_amount: float |
| 40 | + items: List[dict] |
| 41 | + confidence: str |
| 42 | + source_profile: str |
| 43 | + |
| 44 | + |
| 45 | +class AmazonLinker: |
| 46 | + """ |
| 47 | + Links transactions to Amazon orders by matching amount and date. |
| 48 | +
|
| 49 | + This service searches Amazon profile databases for orders that match |
| 50 | + a given transaction amount (within tolerance) and date (within date range). |
| 51 | +
|
| 52 | + Usage: |
| 53 | + linker = AmazonLinker(config_dir=Path.home() / ".moneyflow") |
| 54 | +
|
| 55 | + # Check if merchant looks like Amazon |
| 56 | + if linker.is_amazon_merchant("AMZN MKTP US"): |
| 57 | + matches = linker.find_matching_orders( |
| 58 | + amount=-37.98, |
| 59 | + transaction_date="2025-01-15", |
| 60 | + ) |
| 61 | + for match in matches: |
| 62 | + print(f"Order {match.order_id}: {match.total_amount}") |
| 63 | + """ |
| 64 | + |
| 65 | + # Patterns that identify Amazon merchants (case-insensitive) |
| 66 | + AMAZON_PATTERNS = ["amazon", "amzn"] |
| 67 | + |
| 68 | + # Default amount tolerance (allows for rounding differences) |
| 69 | + AMOUNT_TOLERANCE = 0.02 |
| 70 | + |
| 71 | + def __init__(self, config_dir: Path): |
| 72 | + """ |
| 73 | + Initialize the Amazon linker. |
| 74 | +
|
| 75 | + Args: |
| 76 | + config_dir: Path to moneyflow config directory (e.g., ~/.moneyflow) |
| 77 | + """ |
| 78 | + self.config_dir = Path(config_dir) |
| 79 | + self.profiles_dir = self.config_dir / "profiles" |
| 80 | + |
| 81 | + def is_amazon_merchant(self, merchant_name: str) -> bool: |
| 82 | + """ |
| 83 | + Check if a merchant name looks like Amazon. |
| 84 | +
|
| 85 | + Args: |
| 86 | + merchant_name: Merchant name from transaction |
| 87 | +
|
| 88 | + Returns: |
| 89 | + True if merchant appears to be Amazon |
| 90 | + """ |
| 91 | + if not merchant_name: |
| 92 | + return False |
| 93 | + |
| 94 | + merchant_lower = merchant_name.lower() |
| 95 | + return any(pattern in merchant_lower for pattern in self.AMAZON_PATTERNS) |
| 96 | + |
| 97 | + def find_amazon_databases(self) -> List[Path]: |
| 98 | + """ |
| 99 | + Find all Amazon profile databases. |
| 100 | +
|
| 101 | + Searches for amazon.db files in profiles named "amazon" or starting with "amazon-". |
| 102 | +
|
| 103 | + Returns: |
| 104 | + List of paths to Amazon databases |
| 105 | + """ |
| 106 | + databases = [] |
| 107 | + |
| 108 | + if not self.profiles_dir.exists(): |
| 109 | + return databases |
| 110 | + |
| 111 | + for profile_dir in self.profiles_dir.iterdir(): |
| 112 | + if not profile_dir.is_dir(): |
| 113 | + continue |
| 114 | + |
| 115 | + # Look in profiles named "amazon" or starting with "amazon-" |
| 116 | + profile_name = profile_dir.name |
| 117 | + if profile_name != "amazon" and not profile_name.startswith("amazon-"): |
| 118 | + continue |
| 119 | + |
| 120 | + db_path = profile_dir / "amazon.db" |
| 121 | + if db_path.exists(): |
| 122 | + databases.append(db_path) |
| 123 | + |
| 124 | + return databases |
| 125 | + |
| 126 | + def find_matching_orders( |
| 127 | + self, |
| 128 | + amount: float, |
| 129 | + transaction_date: str, |
| 130 | + date_tolerance_days: int = 7, |
| 131 | + amount_tolerance: Optional[float] = None, |
| 132 | + ) -> List[AmazonOrderMatch]: |
| 133 | + """ |
| 134 | + Find Amazon orders matching the given amount and date. |
| 135 | +
|
| 136 | + Args: |
| 137 | + amount: Transaction amount to match (negative for expenses) |
| 138 | + transaction_date: Transaction date (YYYY-MM-DD format) |
| 139 | + date_tolerance_days: Days +/- to search for matching orders |
| 140 | + amount_tolerance: Amount tolerance for matching (default 0.02) |
| 141 | +
|
| 142 | + Returns: |
| 143 | + List of matching orders, sorted by date proximity (closest first) |
| 144 | + """ |
| 145 | + if amount_tolerance is None: |
| 146 | + amount_tolerance = self.AMOUNT_TOLERANCE |
| 147 | + |
| 148 | + databases = self.find_amazon_databases() |
| 149 | + if not databases: |
| 150 | + return [] |
| 151 | + |
| 152 | + # Parse transaction date |
| 153 | + try: |
| 154 | + txn_date = datetime.strptime(transaction_date, "%Y-%m-%d").date() |
| 155 | + except ValueError: |
| 156 | + logger.warning(f"Invalid transaction date format: {transaction_date}") |
| 157 | + return [] |
| 158 | + |
| 159 | + # Calculate date range |
| 160 | + start_date = txn_date - timedelta(days=date_tolerance_days) |
| 161 | + end_date = txn_date + timedelta(days=date_tolerance_days) |
| 162 | + |
| 163 | + all_matches: List[AmazonOrderMatch] = [] |
| 164 | + |
| 165 | + for db_path in databases: |
| 166 | + try: |
| 167 | + matches = self._search_database( |
| 168 | + db_path=db_path, |
| 169 | + amount=amount, |
| 170 | + amount_tolerance=amount_tolerance, |
| 171 | + start_date=start_date, |
| 172 | + end_date=end_date, |
| 173 | + txn_date=txn_date, |
| 174 | + ) |
| 175 | + all_matches.extend(matches) |
| 176 | + except Exception as e: |
| 177 | + logger.warning(f"Error searching database {db_path}: {e}") |
| 178 | + continue |
| 179 | + |
| 180 | + # Sort by date proximity (closest to transaction date first) |
| 181 | + all_matches.sort(key=lambda m: abs((self._parse_date(m.order_date) - txn_date).days)) |
| 182 | + |
| 183 | + return all_matches |
| 184 | + |
| 185 | + def _parse_date(self, date_str: str) -> date: |
| 186 | + """Parse a date string to date object.""" |
| 187 | + return datetime.strptime(date_str, "%Y-%m-%d").date() |
| 188 | + |
| 189 | + def _search_database( |
| 190 | + self, |
| 191 | + db_path: Path, |
| 192 | + amount: float, |
| 193 | + amount_tolerance: float, |
| 194 | + start_date: date, |
| 195 | + end_date: date, |
| 196 | + txn_date: date, |
| 197 | + ) -> List[AmazonOrderMatch]: |
| 198 | + """ |
| 199 | + Search a single Amazon database for matching orders. |
| 200 | +
|
| 201 | + Args: |
| 202 | + db_path: Path to Amazon database |
| 203 | + amount: Amount to match |
| 204 | + amount_tolerance: Amount tolerance |
| 205 | + start_date: Start of date range |
| 206 | + end_date: End of date range |
| 207 | + txn_date: Transaction date (for confidence calculation) |
| 208 | +
|
| 209 | + Returns: |
| 210 | + List of matching orders from this database |
| 211 | + """ |
| 212 | + matches = [] |
| 213 | + profile_name = db_path.parent.name |
| 214 | + |
| 215 | + try: |
| 216 | + conn = sqlite3.connect(db_path) |
| 217 | + conn.row_factory = sqlite3.Row |
| 218 | + |
| 219 | + # Query to aggregate orders and filter by date range |
| 220 | + # We'll do amount filtering in Python for flexibility |
| 221 | + query = """ |
| 222 | + SELECT |
| 223 | + order_id, |
| 224 | + MIN(date) as order_date, |
| 225 | + SUM(amount) as total_amount, |
| 226 | + GROUP_CONCAT(id) as item_ids |
| 227 | + FROM transactions |
| 228 | + WHERE date >= ? AND date <= ? |
| 229 | + GROUP BY order_id |
| 230 | + """ |
| 231 | + |
| 232 | + cursor = conn.execute( |
| 233 | + query, |
| 234 | + (start_date.isoformat(), end_date.isoformat()), |
| 235 | + ) |
| 236 | + |
| 237 | + for row in cursor: |
| 238 | + order_total = row["total_amount"] |
| 239 | + |
| 240 | + # Check amount match within tolerance |
| 241 | + if abs(order_total - amount) > amount_tolerance: |
| 242 | + continue |
| 243 | + |
| 244 | + # Fetch item details for this order |
| 245 | + items = self._fetch_order_items(conn, row["order_id"]) |
| 246 | + |
| 247 | + # Determine confidence |
| 248 | + order_date = self._parse_date(row["order_date"]) |
| 249 | + days_diff = abs((order_date - txn_date).days) |
| 250 | + amount_diff = abs(order_total - amount) |
| 251 | + |
| 252 | + if amount_diff < 0.01 and days_diff <= 2: |
| 253 | + confidence = "high" |
| 254 | + else: |
| 255 | + confidence = "medium" |
| 256 | + |
| 257 | + match = AmazonOrderMatch( |
| 258 | + order_id=row["order_id"], |
| 259 | + order_date=row["order_date"], |
| 260 | + total_amount=round(order_total, 2), |
| 261 | + items=items, |
| 262 | + confidence=confidence, |
| 263 | + source_profile=profile_name, |
| 264 | + ) |
| 265 | + matches.append(match) |
| 266 | + |
| 267 | + conn.close() |
| 268 | + |
| 269 | + except sqlite3.DatabaseError as e: |
| 270 | + logger.warning(f"Database error reading {db_path}: {e}") |
| 271 | + return [] |
| 272 | + |
| 273 | + return matches |
| 274 | + |
| 275 | + def _fetch_order_items(self, conn: sqlite3.Connection, order_id: str) -> List[dict]: |
| 276 | + """ |
| 277 | + Fetch all items for a given order. |
| 278 | +
|
| 279 | + Args: |
| 280 | + conn: Database connection |
| 281 | + order_id: Order ID to fetch items for |
| 282 | +
|
| 283 | + Returns: |
| 284 | + List of item dicts with name, amount, quantity, asin |
| 285 | + """ |
| 286 | + cursor = conn.execute( |
| 287 | + """ |
| 288 | + SELECT merchant as name, amount, quantity, asin |
| 289 | + FROM transactions |
| 290 | + WHERE order_id = ? |
| 291 | + ORDER BY merchant |
| 292 | + """, |
| 293 | + (order_id,), |
| 294 | + ) |
| 295 | + |
| 296 | + items = [] |
| 297 | + for row in cursor: |
| 298 | + items.append( |
| 299 | + { |
| 300 | + "name": row["name"], |
| 301 | + "amount": row["amount"], |
| 302 | + "quantity": row["quantity"], |
| 303 | + "asin": row["asin"], |
| 304 | + } |
| 305 | + ) |
| 306 | + |
| 307 | + return items |
0 commit comments