ynab-amazon-categorizer 2.1.8__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. ynab_amazon_categorizer-2.2.2/CHANGELOG.md +47 -0
  2. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/PKG-INFO +2 -2
  3. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/pyproject.toml +1 -1
  4. ynab_amazon_categorizer-2.2.2/src/ynab_amazon_categorizer/_version.py +24 -0
  5. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/amazon_parser.py +71 -34
  6. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/cli.py +74 -113
  7. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/config.py +12 -5
  8. ynab_amazon_categorizer-2.2.2/src/ynab_amazon_categorizer/exceptions.py +39 -0
  9. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/memo_generator.py +39 -1
  10. ynab_amazon_categorizer-2.2.2/src/ynab_amazon_categorizer/transaction_matcher.py +93 -0
  11. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/ynab_client.py +66 -13
  12. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +2 -2
  13. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/requires.txt +1 -1
  14. ynab_amazon_categorizer-2.2.2/tests/test_amazon_parser.py +224 -0
  15. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/tests/test_cli_functions.py +66 -23
  16. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/tests/test_config.py +26 -3
  17. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/tests/test_memo_generator.py +62 -6
  18. ynab_amazon_categorizer-2.2.2/tests/test_transaction_matcher.py +199 -0
  19. ynab_amazon_categorizer-2.2.2/tests/test_ynab_client.py +245 -0
  20. ynab_amazon_categorizer-2.1.8/CHANGELOG.md +0 -26
  21. ynab_amazon_categorizer-2.1.8/src/ynab_amazon_categorizer/_version.py +0 -34
  22. ynab_amazon_categorizer-2.1.8/src/ynab_amazon_categorizer/exceptions.py +0 -7
  23. ynab_amazon_categorizer-2.1.8/src/ynab_amazon_categorizer/transaction_matcher.py +0 -85
  24. ynab_amazon_categorizer-2.1.8/tests/test_amazon_parser.py +0 -50
  25. ynab_amazon_categorizer-2.1.8/tests/test_transaction_matcher.py +0 -196
  26. ynab_amazon_categorizer-2.1.8/tests/test_ynab_client.py +0 -145
  27. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.env.example +0 -0
  28. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.git_archival.txt +0 -0
  29. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.gitattributes +0 -0
  30. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.github/workflows/release.yml +0 -0
  31. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.gitignore +0 -0
  32. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/.pre-commit-config.yaml +0 -0
  33. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/AGENTS.md +0 -0
  34. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/CLAUDE.md +0 -0
  35. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/DEVELOPMENT.md +0 -0
  36. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/LICENSE +0 -0
  37. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/README.md +0 -0
  38. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/setup.cfg +0 -0
  39. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/__init__.py +0 -0
  40. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer/__main__.py +0 -0
  41. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +0 -0
  42. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
  43. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
  44. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
  45. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/tests/__init__.py +0 -0
  46. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/tests/test_amazon_parser_real.py +0 -0
  47. {ynab_amazon_categorizer-2.1.8 → ynab_amazon_categorizer-2.2.2}/uv.lock +0 -0
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [2.2.2] - 2026-04-23
11
+
12
+ ### Fixed
13
+
14
+ - Pin `ty==0.0.14` in dev dependencies so CI uses the same type-checker version as the lock file
15
+
16
+ ## [2.2.1] - 2026-04-23
17
+
18
+ ### Fixed
19
+
20
+ - Cancelled orders no longer cause their items to bleed into the preceding valid order
21
+ - Quantity badge numbers (e.g. trailing ` 2`, ` 4`) are stripped from item names when the bare form also appears, without corrupting size numbers like `Size 10`
22
+ - `"Now arriving today X:XX p.m."` delivery status lines are no longer extracted as item names
23
+
24
+ ## [2.2.0] - 2026-02-23
25
+
26
+ ### Fixed
27
+
28
+ - Comprehensive audit fixes across all modules
29
+ - Preserve Amazon order link in memo truncation when prefix space is too small
30
+
31
+ ## [2.1.8] - 2026-02-05
32
+
33
+ ### Fixed
34
+
35
+ - Ruff formatting compliance for CI
36
+
37
+ ## [2.1.7] - 2026-02-04
38
+
39
+ ### Changed
40
+
41
+ - Extracted testable functions from `cli.py` for better modularity
42
+ - Added retry logic to YNAB API client (3 retries with backoff on 429/5xx)
43
+ - Added stdlib logging throughout the codebase
44
+
45
+ ### Added
46
+
47
+ - Comprehensive test suite for extracted CLI functions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ynab-amazon-categorizer
3
- Version: 2.1.8
3
+ Version: 2.2.2
4
4
  Summary: Automatically categorize Amazon transactions in YNAB with rich item information
5
5
  Author: dizzlkheinz
6
6
  License: GPL-3.0
@@ -28,7 +28,7 @@ Provides-Extra: dev
28
28
  Requires-Dist: pytest>=9.0.2; extra == "dev"
29
29
  Requires-Dist: pytest-cov>=7.0.0; extra == "dev"
30
30
  Requires-Dist: ruff>=0.15.0; extra == "dev"
31
- Requires-Dist: ty>=0.0.14; extra == "dev"
31
+ Requires-Dist: ty==0.0.14; extra == "dev"
32
32
  Requires-Dist: pre-commit>=4.5.1; extra == "dev"
33
33
  Dynamic: license-file
34
34
 
@@ -36,7 +36,7 @@ dev = [
36
36
  "pytest>=9.0.2",
37
37
  "pytest-cov>=7.0.0",
38
38
  "ruff>=0.15.0",
39
- "ty>=0.0.14",
39
+ "ty==0.0.14",
40
40
  "pre-commit>=4.5.1",
41
41
  ]
42
42
 
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.2.2'
22
+ __version_tuple__ = version_tuple = (2, 2, 2)
23
+
24
+ __commit_id__ = commit_id = 'g993627dd7'
@@ -1,7 +1,13 @@
1
1
  """Amazon order parsing functionality."""
2
2
 
3
+ import logging
3
4
  import re
4
5
 
6
+ logger = logging.getLogger(__name__)
7
+
8
+ # Maximum items to extract per order (keeps memos manageable)
9
+ MAX_ITEMS_PER_ORDER = 10
10
+
5
11
 
6
12
  class Order:
7
13
  """Represents a parsed Amazon order."""
@@ -16,64 +22,87 @@ class Order:
16
22
  class AmazonParser:
17
23
  """Parses Amazon order data from order history pages."""
18
24
 
25
+ def _remove_cancelled_orders(self, text: str) -> str:
26
+ """Remove cancelled order blocks so their items don't bleed into adjacent orders."""
27
+ parts = re.split(r"(?=Order placed)", text)
28
+ kept = []
29
+ for part in parts:
30
+ if (
31
+ re.match(r"\s*Order placed", part)
32
+ and "Your order was cancelled" in part
33
+ ):
34
+ continue
35
+ kept.append(part)
36
+ return "".join(kept)
37
+
19
38
  def parse_orders_page(self, orders_text: str) -> list[Order]:
20
- """Parse Amazon orders page text to extract order information"""
39
+ """Parse Amazon orders page text to extract order information.
40
+
41
+ Orders are kept even when item extraction fails (partial orders)
42
+ so that amount/date matching can still work.
43
+ """
21
44
  if not orders_text.strip():
22
45
  return []
23
46
 
47
+ orders_text = self._remove_cancelled_orders(orders_text)
48
+
24
49
  orders = []
25
50
 
26
51
  # Find all order blocks using regex
27
52
  order_pattern = r"Order placed\s*([A-Za-z]+ \d+, \d{4})\s*Total\s*\$(\d+\.?\d*)\s*.*?Order # (\d{3}-\d{7}-\d{7})"
28
- order_matches = re.finditer(
29
- order_pattern, orders_text, re.DOTALL | re.IGNORECASE
53
+ order_matches = list(
54
+ re.finditer(order_pattern, orders_text, re.DOTALL | re.IGNORECASE)
30
55
  )
31
56
 
32
- for match in order_matches:
57
+ for idx, match in enumerate(order_matches):
33
58
  order_date = match.group(1).strip()
34
59
  order_total = float(match.group(2))
35
60
  order_id = match.group(3)
36
61
 
37
62
  # Find the content after this order until the next order or end
38
63
  start_pos = match.end()
39
- next_order = re.search(
40
- r"Order placed", orders_text[start_pos:], re.IGNORECASE
41
- )
42
- if next_order:
43
- end_pos = start_pos + next_order.start()
44
- order_content = orders_text[start_pos:end_pos]
64
+ if idx + 1 < len(order_matches):
65
+ end_pos = order_matches[idx + 1].start()
45
66
  else:
46
- order_content = orders_text[
47
- start_pos : start_pos + 2000
48
- ] # Take next 2000 chars
67
+ end_pos = len(orders_text)
68
+ order_content = orders_text[start_pos:end_pos]
49
69
 
50
70
  # Extract items from the order content
51
71
  items = self.extract_items_from_content(order_content)
52
72
 
53
- if items: # Only add orders that have identifiable items
54
- order = Order()
55
- order.order_id = order_id
56
- order.total = order_total
57
- order.date_str = order_date
58
- order.items = items
59
- orders.append(order)
73
+ # Always keep the order even without items (partial order)
74
+ order = Order()
75
+ order.order_id = order_id
76
+ order.total = order_total
77
+ order.date_str = order_date
78
+ order.items = items
79
+
80
+ if not items:
81
+ logger.info(
82
+ "Order %s parsed without items (amount=%.2f). "
83
+ "It can still match by amount/date.",
84
+ order_id,
85
+ order_total,
86
+ )
87
+
88
+ orders.append(order)
60
89
 
61
90
  return orders
62
91
 
63
92
  def extract_items_from_content(self, order_content: str) -> list[str]:
64
93
  """Extract item names from order content."""
65
- items = []
94
+ candidates: list[str] = []
66
95
  lines = order_content.split("\n")
67
96
 
68
97
  for line in lines:
69
98
  line = line.strip()
70
- if not line or len(line) < 20:
99
+ if not line or len(line) < 15:
71
100
  continue
72
101
 
73
- # Skip common UI elements
102
+ # Skip common UI elements and delivery status lines
74
103
  skip_patterns = [
75
104
  r"^(Buy it again|Track package|View|Return|Write|Get|Share|Leave|Ask)",
76
- r"^(Delivered|Arriving|Auto-delivered|Package was)",
105
+ r"^(Delivered|Arriving|Now arriving|Auto-delivered|Package was)",
77
106
  r"^(Return items:|Return or replace)",
78
107
  r"^\d+\.?\d* out of \d+ stars",
79
108
  r"^FREE|^Today by|^Get it|^List:|^Was:|^Limited-time deal",
@@ -125,16 +154,24 @@ class AmazonParser:
125
154
  "shipping",
126
155
  ]
127
156
  if not any(word in cleaned_line.lower() for word in skip_words):
128
- items.append(cleaned_line)
129
-
130
- # Remove duplicates and limit items
131
- seen = set()
132
- unique_items = []
133
- for item in items:
134
- if item not in seen and len(item) > 15: # Only keep substantial items
135
- seen.add(item)
136
- unique_items.append(item)
137
- if len(unique_items) >= 3: # Limit to 3 items
157
+ candidates.append(cleaned_line)
158
+
159
+ # Build lookup set to detect quantity-badge duplicates.
160
+ # Amazon shows "Product Name <qty>" and "Product Name" on adjacent lines when
161
+ # qty > 1. We want to strip the badge only when the bare form also appears.
162
+ candidate_set = set(candidates)
163
+
164
+ seen: set[str] = set()
165
+ unique_items: list[str] = []
166
+ for item in candidates:
167
+ stripped = re.sub(r"\s+\d+$", "", item)
168
+ normalized = (
169
+ stripped if (stripped != item and stripped in candidate_set) else item
170
+ )
171
+ if normalized not in seen and len(normalized) > 15:
172
+ seen.add(normalized)
173
+ unique_items.append(normalized)
174
+ if len(unique_items) >= MAX_ITEMS_PER_ORDER:
138
175
  break
139
176
 
140
177
  return unique_items
@@ -1,11 +1,12 @@
1
1
  import copy
2
2
  import json
3
3
  import logging
4
- import os # For history file path
4
+ import os
5
+ import sys
5
6
  from collections.abc import Iterable
6
7
  from typing import Any
7
8
 
8
- # --- NEW: Import prompt_toolkit components ---
9
+ import requests
9
10
  from prompt_toolkit import prompt
10
11
  from prompt_toolkit.completion import CompleteEvent, Completer, Completion
11
12
  from prompt_toolkit.document import Document
@@ -13,11 +14,9 @@ from prompt_toolkit.history import FileHistory
13
14
  from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
14
15
 
15
16
  from .amazon_parser import AmazonParser, Order
16
-
17
- # --- END NEW ---
18
- # --- Import extracted modules ---
19
17
  from .config import Config
20
- from .memo_generator import MemoGenerator
18
+ from .exceptions import ConfigurationError, YNABAPIError
19
+ from .memo_generator import MemoGenerator, sanitize_memo
21
20
  from .transaction_matcher import TransactionMatcher
22
21
  from .ynab_client import YNABClient
23
22
 
@@ -29,14 +28,6 @@ logger = logging.getLogger(__name__)
29
28
  AMAZON_PAYEE_KEYWORDS = ["amazon", "amzn", "amz"]
30
29
  YNAB_API_URL = "https://api.ynab.com/v1"
31
30
 
32
- # --- Amazon Order Link Functions ---
33
-
34
-
35
- # NOTE: Memo generation functions have been moved to memo_generator.py
36
-
37
-
38
- # NOTE: Amazon order parsing has been moved to amazon_parser.py
39
-
40
31
 
41
32
  def prompt_for_amazon_orders_data() -> list[Order] | None:
42
33
  """Prompt user to paste Amazon orders page data"""
@@ -73,9 +64,6 @@ def prompt_for_amazon_orders_data() -> list[Order] | None:
73
64
  return parsed_orders
74
65
 
75
66
 
76
- # NOTE: find_matching_order moved to transaction_matcher.py
77
-
78
-
79
67
  def get_multiline_input_with_custom_submit(
80
68
  prompt_message: str = "Enter multiline text: ",
81
69
  ) -> str | None:
@@ -113,14 +101,14 @@ def generate_split_summary_memo(matching_order: Order) -> str:
113
101
 
114
102
  items = matching_order.items
115
103
  if len(items) == 1:
116
- return items[0]
104
+ return sanitize_memo(items[0])
117
105
 
118
106
  # Format as: "2 Items:\n- Item 1\n- Item 2"
119
107
  summary = f"{len(items)} Items:"
120
108
  for item in items:
121
109
  summary += f"\n- {item}"
122
110
 
123
- return summary
111
+ return sanitize_memo(summary)
124
112
 
125
113
 
126
114
  def prompt_for_item_details() -> dict[str, str | int | float | list[str] | None] | None:
@@ -180,7 +168,7 @@ def print_config_summary(config: Config) -> None:
180
168
  print(f"✓ Budget ID: ...{config.budget_id[-4:]}")
181
169
  else:
182
170
  print("✓ Budget ID: configured")
183
- if config.account_id and config.account_id.lower() != "none":
171
+ if config.account_id:
184
172
  print("✓ Account ID: configured")
185
173
  else:
186
174
  print("✓ All accounts")
@@ -252,7 +240,7 @@ def build_single_payload(
252
240
  "payee_id": transaction.get("payee_id"),
253
241
  "payee_name": transaction.get("payee_name", "N/A"),
254
242
  "category_id": category_id,
255
- "memo": memo,
243
+ "memo": sanitize_memo(memo),
256
244
  "cleared": transaction.get("cleared"),
257
245
  "approved": True,
258
246
  "flag_color": transaction.get("flag_color"),
@@ -263,10 +251,15 @@ def build_single_payload(
263
251
  def build_split_payload(
264
252
  transaction: dict[str, Any],
265
253
  subtransactions: list[dict[str, int | str | None]],
266
- matching_order: Order | dict[str, Any] | None,
254
+ matching_order: Order | None,
267
255
  original_memo: str,
268
256
  ) -> dict[str, Any]:
269
257
  """Build update payload for a split transaction."""
258
+ memo = (
259
+ generate_split_summary_memo(matching_order)
260
+ if matching_order
261
+ else sanitize_memo(original_memo)
262
+ )
270
263
  return {
271
264
  "id": transaction["id"],
272
265
  "account_id": transaction["account_id"],
@@ -275,9 +268,7 @@ def build_split_payload(
275
268
  "payee_id": transaction.get("payee_id"),
276
269
  "payee_name": transaction.get("payee_name", "N/A"),
277
270
  "category_id": None,
278
- "memo": generate_split_summary_memo(matching_order)
279
- if matching_order and isinstance(matching_order, Order)
280
- else original_memo,
271
+ "memo": memo,
281
272
  "cleared": transaction.get("cleared"),
282
273
  "approved": True,
283
274
  "flag_color": transaction.get("flag_color"),
@@ -291,7 +282,7 @@ def fetch_amazon_transactions(
291
282
  ) -> list[dict[str, Any]]:
292
283
  """Fetch transactions from YNAB and filter to uncategorized Amazon ones."""
293
284
  transactions_endpoint = f"/budgets/{config.budget_id}/transactions"
294
- if config.account_id and config.account_id.lower() != "none":
285
+ if config.account_id:
295
286
  transactions_endpoint = (
296
287
  f"/budgets/{config.budget_id}/accounts/{config.account_id}/transactions"
297
288
  )
@@ -319,24 +310,13 @@ def fetch_amazon_transactions(
319
310
  and t.get("amount", 0) != 0
320
311
  and t.get("transfer_account_id") is None
321
312
  and not t.get("subtransactions")
322
- and t.get("import_id") is not None
323
313
  )
324
314
  if is_valid_for_processing:
325
315
  transactions_to_process.append(t)
326
316
  return transactions_to_process
327
317
 
328
318
 
329
- # NOTE: YNAB API functions have been moved to ynab_client.py
330
-
331
-
332
- # NOTE: update_ynab_transaction moved to ynab_client.py
333
-
334
-
335
- # NOTE: get_categories moved to ynab_client.py
336
-
337
-
338
319
  class CategoryCompleter(Completer):
339
- # ... (implementation from v3) ...
340
320
  def __init__(self, category_list: list[tuple[str, str]]) -> None:
341
321
  self.categories = [name for name, _id in category_list]
342
322
  self.category_list = category_list
@@ -356,7 +336,6 @@ class CategoryCompleter(Completer):
356
336
  def prompt_for_category_selection(
357
337
  category_completer: CategoryCompleter, name_to_id_map: dict[str, str]
358
338
  ) -> tuple[str | None, str | None]:
359
- # ... (implementation from v3) ...
360
339
  history_file = os.path.join(os.path.expanduser("~"), ".ynab_amazon_cat_history")
361
340
  history = FileHistory(history_file)
362
341
  while True:
@@ -394,43 +373,34 @@ def prompt_for_category_selection(
394
373
  # --- Extracted per-transaction functions ---
395
374
 
396
375
 
397
- def display_matched_order(
398
- matching_order: Order | dict[str, Any], memo_generator: MemoGenerator
399
- ) -> None:
376
+ def display_matched_order(matching_order: Order, memo_generator: MemoGenerator) -> None:
400
377
  """Display matched order details to the user."""
401
- if isinstance(matching_order, Order):
402
- order_id = matching_order.order_id
403
- total = matching_order.total
404
- date_str = matching_order.date_str
405
- items = matching_order.items
406
- else:
407
- order_id = matching_order.get("order_id")
408
- total = matching_order.get("total")
409
- date_str = matching_order.get("date") or matching_order.get("date_str")
410
- items = matching_order.get("items", [])
411
-
412
378
  print("\n 🎯 MATCHED ORDER FOUND:")
413
- print(f" Order ID: {order_id}")
414
- print(f" Total: ${total if total is not None else 'N/A'}")
415
- print(f" Date: {date_str if date_str is not None else 'N/A'}")
416
- order_link = memo_generator.generate_amazon_order_link(order_id)
379
+ print(f" Order ID: {matching_order.order_id}")
380
+ print(
381
+ f" Total: ${matching_order.total if matching_order.total is not None else 'N/A'}"
382
+ )
383
+ print(
384
+ f" Date: {matching_order.date_str if matching_order.date_str is not None else 'N/A'}"
385
+ )
386
+ order_link = memo_generator.generate_amazon_order_link(matching_order.order_id)
417
387
  print(f" Order Link: {order_link}")
418
- if items:
388
+ if matching_order.items:
419
389
  print(" Items:")
420
- for item in items:
390
+ for item in matching_order.items:
421
391
  print(f" - {item}")
422
392
  print()
423
393
 
424
394
 
425
395
  def resolve_memo(
426
- matching_order: Order | dict[str, Any] | None,
396
+ matching_order: Order | None,
427
397
  original_memo: str,
428
398
  memo_generator: MemoGenerator,
429
399
  ) -> str:
430
400
  """Determine the memo for a single-category transaction.
431
401
 
432
402
  Uses matched order data when available, otherwise prompts for manual entry.
433
- Returns the final memo string.
403
+ Returns the final memo string (already sanitized).
434
404
  """
435
405
  item_details: dict[str, str | int | float | list[str] | None] | None = None
436
406
  enhanced_memo = None
@@ -438,20 +408,12 @@ def resolve_memo(
438
408
  # Use matched order data or prompt for manual entry
439
409
  if matching_order:
440
410
  print("Using matched order data for memo generation...")
441
- if isinstance(matching_order, Order):
442
- item_details = {
443
- "order_id": matching_order.order_id or "",
444
- "items": matching_order.items,
445
- "total": matching_order.total,
446
- "date": matching_order.date_str,
447
- }
448
- else:
449
- item_details = {
450
- "order_id": matching_order.get("order_id", ""),
451
- "items": matching_order.get("items", []),
452
- "total": matching_order.get("total"),
453
- "date": matching_order.get("date") or matching_order.get("date_str"),
454
- }
411
+ item_details = {
412
+ "order_id": matching_order.order_id or "",
413
+ "items": matching_order.items,
414
+ "total": matching_order.total,
415
+ "date": matching_order.date_str,
416
+ }
455
417
  else:
456
418
  # Ask if user wants to enter item details manually
457
419
  manual_entry = input(
@@ -490,20 +452,20 @@ def resolve_memo(
490
452
  print(f"'{enhanced_memo}'")
491
453
  use_suggested = input("Use suggested memo? (y/n, default y): ").lower()
492
454
  if use_suggested != "n":
493
- return enhanced_memo
455
+ return sanitize_memo(enhanced_memo)
494
456
  else:
495
457
  print("Enter custom memo (multiline):")
496
458
  memo_input = get_multiline_input_with_custom_submit("> ")
497
- return memo_input.strip() if memo_input else ""
459
+ return sanitize_memo(memo_input.strip()) if memo_input else ""
498
460
  else:
499
461
  print("Enter optional memo (multiline):")
500
462
  memo_input = get_multiline_input_with_custom_submit("> ")
501
- return memo_input.strip() if memo_input else ""
463
+ return sanitize_memo(memo_input.strip()) if memo_input else ""
502
464
 
503
465
 
504
466
  def handle_split(
505
467
  transaction: dict[str, Any],
506
- matching_order: Order | dict[str, Any] | None,
468
+ matching_order: Order | None,
507
469
  memo_generator: MemoGenerator,
508
470
  category_completer: CategoryCompleter,
509
471
  category_name_map: dict[str, str],
@@ -524,12 +486,7 @@ def handle_split(
524
486
  )
525
487
 
526
488
  # Show which item this split is for if we have matched order data
527
- items: list[str] = []
528
- if matching_order:
529
- if isinstance(matching_order, Order):
530
- items = matching_order.items or []
531
- else:
532
- items = matching_order.get("items", [])
489
+ items: list[str] = matching_order.items if matching_order else []
533
490
 
534
491
  if items:
535
492
  if split_count <= len(items):
@@ -577,7 +534,7 @@ def handle_split(
577
534
  {
578
535
  "amount": split_amount_milliunits,
579
536
  "category_id": category_id,
580
- "memo": split_memo if split_memo else None,
537
+ "memo": sanitize_memo(split_memo) if split_memo else None,
581
538
  }
582
539
  )
583
540
 
@@ -599,7 +556,7 @@ def handle_split(
599
556
 
600
557
 
601
558
  def _resolve_split_memo(
602
- matching_order: Order | dict[str, Any] | None,
559
+ matching_order: Order | None,
603
560
  memo_generator: MemoGenerator,
604
561
  category_name: str | None,
605
562
  split_count: int,
@@ -609,12 +566,8 @@ def _resolve_split_memo(
609
566
 
610
567
  if matching_order:
611
568
  print("Using matched order data for split memo...")
612
- if isinstance(matching_order, Order):
613
- items = matching_order.items
614
- order_id = matching_order.order_id
615
- else:
616
- items = matching_order.get("items", [])
617
- order_id = matching_order.get("order_id")
569
+ items = matching_order.items
570
+ order_id = matching_order.order_id
618
571
 
619
572
  if split_count <= len(items):
620
573
  items_text = items[split_count - 1]
@@ -691,7 +644,7 @@ def process_transaction(
691
644
  print(f" Original Memo: {original_memo}")
692
645
 
693
646
  # Try to find matching order from parsed data and show it
694
- matching_order: Order | dict[str, Any] | None = None
647
+ matching_order: Order | None = None
695
648
  if parsed_orders:
696
649
  transaction_matcher = TransactionMatcher()
697
650
  matching_order = transaction_matcher.find_matching_order(
@@ -735,7 +688,7 @@ def process_transaction(
735
688
 
736
689
  def _handle_categorize(
737
690
  transaction: dict[str, Any],
738
- matching_order: Order | dict[str, Any] | None,
691
+ matching_order: Order | None,
739
692
  original_memo: str,
740
693
  memo_generator: MemoGenerator,
741
694
  ynab_client: YNABClient,
@@ -752,15 +705,9 @@ def _handle_categorize(
752
705
  updated_payload_dict: dict[str, Any] | None = None
753
706
 
754
707
  # Check if we should offer splitting
755
- should_offer_split = False
756
- if matching_order:
757
- if isinstance(matching_order, Order):
758
- should_offer_split = bool(
759
- matching_order.items and len(matching_order.items) > 1
760
- )
761
- else:
762
- items = matching_order.get("items", [])
763
- should_offer_split = bool(items and len(items) > 1)
708
+ should_offer_split = bool(
709
+ matching_order and matching_order.items and len(matching_order.items) > 1
710
+ )
764
711
 
765
712
  if should_offer_split:
766
713
  print("There is more than one item in this transaction.")
@@ -808,12 +755,13 @@ def _handle_categorize(
808
755
  if not confirm:
809
756
  confirm = "y"
810
757
  if confirm == "y":
811
- if ynab_client.update_transaction(transaction_id, updated_payload_dict):
758
+ try:
759
+ ynab_client.update_transaction(transaction_id, updated_payload_dict)
812
760
  print("Update successful.")
813
761
  return "done"
814
- else:
815
- logger.error("Failed to update transaction %s.", transaction_id)
816
- print("Update failed.")
762
+ except (YNABAPIError, requests.exceptions.RequestException) as exc:
763
+ logger.error("Failed to update transaction %s: %s", transaction_id, exc)
764
+ print(f"Update failed: {exc}")
817
765
  return "continue"
818
766
  else:
819
767
  print("Update cancelled.")
@@ -833,22 +781,29 @@ def main() -> None:
833
781
  try:
834
782
  config = Config.from_env()
835
783
  print_config_summary(config)
836
- except Exception as e:
784
+ except ConfigurationError as e:
837
785
  logger.error("Configuration error: %s", e)
838
786
  print("Please set environment variables or create a .env file.")
839
787
  print("See README.md for setup instructions.")
840
- exit(1)
788
+ sys.exit(1)
841
789
 
842
790
  # Initialize YNAB client
843
791
  ynab_client = YNABClient(config.api_key, config.budget_id)
844
792
  memo_generator = MemoGenerator(config.amazon_domain)
845
793
 
846
794
  print("Fetching categories...")
847
- categories_list, category_name_map, category_id_map = ynab_client.get_categories()
795
+ try:
796
+ categories_list, category_name_map, category_id_map = (
797
+ ynab_client.get_categories()
798
+ )
799
+ except (YNABAPIError, requests.exceptions.RequestException) as exc:
800
+ logger.error("Failed to fetch categories: %s", exc)
801
+ print(f"Could not fetch categories: {exc}")
802
+ sys.exit(1)
848
803
 
849
804
  if not categories_list:
850
805
  print("Exiting due to category fetch error or no usable categories found.")
851
- exit()
806
+ sys.exit(1)
852
807
 
853
808
  category_completer_instance = CategoryCompleter(categories_list)
854
809
  print(f"\nFound {len(categories_list)} usable categories. Completion enabled.")
@@ -879,7 +834,13 @@ def main() -> None:
879
834
  print("No valid orders found in provided data.")
880
835
 
881
836
  print("\nFetching transactions...")
882
- transactions_to_process = fetch_amazon_transactions(ynab_client, config)
837
+ try:
838
+ transactions_to_process = fetch_amazon_transactions(ynab_client, config)
839
+ except (YNABAPIError, requests.exceptions.RequestException) as exc:
840
+ logger.error("Failed to fetch transactions: %s", exc)
841
+ print(f"Could not fetch transactions: {exc}")
842
+ sys.exit(1)
843
+
883
844
  print(
884
845
  f"\nFound {len(transactions_to_process)} uncategorized Amazon transaction(s) needing attention."
885
846
  )
@@ -898,7 +859,7 @@ def main() -> None:
898
859
  category_id_map,
899
860
  )
900
861
  if not should_continue:
901
- exit()
862
+ sys.exit(0)
902
863
 
903
864
  # End of processing loop
904
865
  print("\nFinished processing transactions.")