Skip to content

Commit eec021c

Browse files
wesmclaude
andauthored
feat: Link Amazon orders to Monarch transactions in info modal (#59)
When viewing a Monarch/YNAB transaction with an Amazon-like merchant name (contains "amazon" or "amzn"), pressing "I" now searches local Amazon profile databases for matching orders by amount and date. Features: - New AmazonLinker service to find and match Amazon orders - Matches by order total (sum of items) within $0.02 tolerance - Matches by date within 7 days of transaction date - Shows matched orders at top of transaction detail modal - Displays order ID, date, items with quantities, and totals - Shows "No matching orders found" when searched but no matches Bug fix: - Fixed Amazon mode failing with "encryption key not set" error - Cache manager now only created when encryption key is available Tests (30 new tests): - AmazonLinker matching logic and edge cases - TransactionDetailScreen with Amazon matches - Regression tests for Amazon mode without encryption 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 29308b1 commit eec021c

File tree

7 files changed

+1317
-8
lines changed

7 files changed

+1317
-8
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ If you need to adjust the bot's behavior:
369369
- ✅ AI can create commits locally
370370
- ❌ AI must NEVER push to git without explicit user permission
371371
- ❌ AI must NEVER create new branches unless explicitly asked by the user
372+
- ❌ AI must NEVER amend commits unless explicitly asked by the user
372373
- 💡 User should review commits before pushing
373374

374375
```bash

docs/guide/amazon-mode.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,45 @@ moneyflow amazon import ~/Downloads/"Your Orders"
137137

138138
Cancelled orders are automatically skipped during import.
139139

140+
### Transaction Linking with Monarch/YNAB
141+
142+
When you use Amazon mode alongside Monarch Money or YNAB, moneyflow can automatically link Amazon orders to
143+
transactions in your bank accounts.
144+
145+
**How it works:**
146+
147+
When viewing a transaction in Monarch or YNAB that has an Amazon-like merchant name (e.g., "Amazon.com",
148+
"AMZN MKTP US"), pressing ++i++ (Info) will:
149+
150+
1. Search your Amazon database for matching orders
151+
2. Match by amount (within $0.02 tolerance) and date (within 7 days)
152+
3. Display matched orders at the top of the transaction details
153+
154+
**Example:**
155+
156+
You have a $47.98 charge from "AMZN MKTP US" on your credit card. Pressing ++i++ shows:
157+
158+
```text
159+
Matching Amazon Orders
160+
───────────────────────────────────────
161+
Order: 113-1234567-8901234*
162+
Date: 2025-01-10 | From: amazon
163+
USB-C Cable (x2): -$12.99
164+
Wireless Mouse: -$24.99
165+
Total: -$37.98
166+
───────────────────────────────────────
167+
```
168+
169+
The `*` indicates a high-confidence match (exact amount and close date).
170+
171+
**Requirements:**
172+
173+
- Import your Amazon purchase history first (`moneyflow amazon import`)
174+
- Transaction must have "amazon" or "amzn" in the merchant name
175+
- Amount and date must be within tolerance
176+
177+
This feature helps you identify exactly what items were in each Amazon charge, making categorization easier.
178+
140179
### Incremental Imports
141180

142181
Amazon mode supports incremental imports, preserving any manual edits you've made:

moneyflow/amazon_linker.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)