ynab-amazon-categorizer 2.2.3__tar.gz → 2.3.0__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 (42) hide show
  1. ynab_amazon_categorizer-2.3.0/.github/workflows/ci.yml +65 -0
  2. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.gitignore +9 -0
  3. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/CHANGELOG.md +29 -0
  4. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/DEVELOPMENT.md +1 -1
  5. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/PKG-INFO +33 -2
  6. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/README.md +32 -1
  7. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/_version.py +3 -3
  8. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/amazon_parser.py +5 -3
  9. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/cli.py +196 -21
  10. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/transaction_matcher.py +63 -0
  11. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +33 -2
  12. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +1 -0
  13. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_amazon_parser.py +16 -0
  14. ynab_amazon_categorizer-2.3.0/tests/test_amazon_parser_real.py +175 -0
  15. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_cli_functions.py +351 -0
  16. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_transaction_matcher.py +85 -0
  17. ynab_amazon_categorizer-2.2.3/tests/test_amazon_parser_real.py +0 -49
  18. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.env.example +0 -0
  19. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.git_archival.txt +0 -0
  20. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.gitattributes +0 -0
  21. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.github/workflows/release.yml +0 -0
  22. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.pre-commit-config.yaml +0 -0
  23. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/AGENTS.md +0 -0
  24. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/CLAUDE.md +0 -0
  25. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/LICENSE +0 -0
  26. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/pyproject.toml +0 -0
  27. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/setup.cfg +0 -0
  28. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__init__.py +0 -0
  29. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__main__.py +0 -0
  30. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/config.py +0 -0
  31. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/exceptions.py +0 -0
  32. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
  33. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/ynab_client.py +0 -0
  34. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
  35. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
  36. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
  37. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
  38. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/__init__.py +0 -0
  39. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_config.py +0 -0
  40. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_memo_generator.py +0 -0
  41. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_ynab_client.py +0 -0
  42. {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/uv.lock +0 -0
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ name: test (${{ matrix.os }}, py${{ matrix.python-version }})
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ os: [ubuntu-latest, windows-latest]
20
+ python-version: ["3.12", "3.13"]
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v5
24
+ with:
25
+ fetch-depth: 0 # Fetch full history and tags for setuptools-scm
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v6
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ - name: Install dependencies
31
+ run: uv sync --extra dev
32
+ - name: Run tests
33
+ run: uv run python -X utf8 -m pytest tests/ -v
34
+
35
+ lint:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - name: Checkout code
39
+ uses: actions/checkout@v5
40
+ - name: Install uv
41
+ uses: astral-sh/setup-uv@v6
42
+ with:
43
+ python-version: "3.12"
44
+ - name: Install dependencies
45
+ run: uv sync --extra dev
46
+ - name: Ruff format check
47
+ run: uv run --extra dev ruff format --check src tests
48
+ - name: Ruff lint
49
+ run: uv run --extra dev ruff check src tests
50
+
51
+ type-check:
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - name: Checkout code
55
+ uses: actions/checkout@v5
56
+ with:
57
+ fetch-depth: 0 # Fetch full history and tags for setuptools-scm
58
+ - name: Install uv
59
+ uses: astral-sh/setup-uv@v6
60
+ with:
61
+ python-version: "3.12"
62
+ - name: Install dependencies
63
+ run: uv sync --extra dev
64
+ - name: Run type checker
65
+ run: uv run --extra dev ty check src tests
@@ -29,6 +29,15 @@ wheels/
29
29
  venv/
30
30
  env/
31
31
  ENV/
32
+ .venv/
33
+
34
+ # Test / coverage artifacts
35
+ .coverage
36
+ .coverage.*
37
+ htmlcov/
38
+ .pytest_cache/
39
+ .ruff_cache/
40
+ .uv-cache/
32
41
 
33
42
  # IDE
34
43
  .vscode/
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-06-19
11
+
12
+ ### Added
13
+
14
+ - `--dry-run` flag: preview every update without sending changes to YNAB
15
+ - `--batch` flag: non-interactively auto-set memos (items + order link) for confidently matched transactions, leaving categories unchanged; combine with `--dry-run` to preview
16
+ - CI workflow running tests (Ubuntu + Windows, Python 3.12 & 3.13), lint, and type checks on every push and pull request
17
+
18
+ ### Changed
19
+
20
+ - All interactive prompts now go through `prompt_toolkit` for consistent input handling (replaces the builtin `input()`)
21
+
22
+ ### Fixed
23
+
24
+ - A matched Amazon order is no longer reused for multiple same-amount transactions in a single run
25
+ - "Subscribe & Save" / delivery-management lines and refund-status lines are no longer extracted as order items
26
+
27
+ ## [2.2.4] - 2026-05-13
28
+
29
+ ### Fixed
30
+
31
+ - Order totals with thousands separators (e.g. `$1,234.56`) are now parsed correctly
32
+ - Single-category matched orders now show all item names in the suggested memo, not just the first
33
+ - Transaction amount is displayed with the correct sign (no spurious negation)
34
+
35
+ ### Changed
36
+
37
+ - Minimum supported Python version clarified to 3.12+ in README and DEVELOPMENT.md
38
+
10
39
  ## [2.2.3] - 2026-05-05
11
40
 
12
41
  ### Fixed
@@ -4,7 +4,7 @@ Practical commands and conventions for working on this repository.
4
4
 
5
5
  ## Prerequisites
6
6
 
7
- - Python 3.10+
7
+ - Python 3.12+
8
8
  - [uv](https://docs.astral.sh/uv/)
9
9
 
10
10
  ## Setup
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ynab-amazon-categorizer
3
- Version: 2.2.3
3
+ Version: 2.3.0
4
4
  Summary: Automatically categorize Amazon transactions in YNAB with rich item information
5
5
  Author: dizzlkheinz
6
6
  License: GPL-3.0
@@ -49,7 +49,7 @@ When you paste in the text from your Amazon order page:
49
49
 
50
50
  ## Prerequisites
51
51
 
52
- - Python 3.12+ (recommended; 3.10+ supported)
52
+ - Python 3.12+
53
53
  - [uv](https://docs.astral.sh/uv/) (recommended) or standard Python tooling
54
54
  - YNAB account with API access
55
55
 
@@ -138,6 +138,37 @@ ynab-amazon-categorizer
138
138
  python -m ynab_amazon_categorizer
139
139
  ```
140
140
 
141
+ ### Dry run
142
+
143
+ Use `--dry-run` to walk through the full interactive flow and preview every
144
+ update without sending any changes to YNAB:
145
+
146
+ ```bash
147
+ ynab-amazon-categorizer --dry-run
148
+ ```
149
+
150
+ The tool still shows the JSON preview for each transaction but skips the API
151
+ call, so it is safe for trying the tool out or verifying matches.
152
+
153
+ ### Batch mode
154
+
155
+ Use `--batch` to run non-interactively: for every transaction with a single
156
+ high-confidence order match (unique exact-amount match within ~7 days), the
157
+ tool sets the memo (items + order link) automatically and **leaves the category
158
+ unchanged**, so you can still review/categorize later. Transactions with no
159
+ match or an ambiguous match are skipped.
160
+
161
+ ```bash
162
+ # Preview what batch mode would enrich
163
+ ynab-amazon-categorizer --batch --dry-run
164
+
165
+ # Apply memo enrichment
166
+ ynab-amazon-categorizer --batch
167
+ ```
168
+
169
+ You still paste the Amazon orders page once when prompted; `--batch` only
170
+ removes the per-transaction prompting.
171
+
141
172
  ### Workflow
142
173
  1. **Provide Amazon Orders Data** (optional but recommended):
143
174
  - Copy your Amazon orders page content
@@ -15,7 +15,7 @@ When you paste in the text from your Amazon order page:
15
15
 
16
16
  ## Prerequisites
17
17
 
18
- - Python 3.12+ (recommended; 3.10+ supported)
18
+ - Python 3.12+
19
19
  - [uv](https://docs.astral.sh/uv/) (recommended) or standard Python tooling
20
20
  - YNAB account with API access
21
21
 
@@ -104,6 +104,37 @@ ynab-amazon-categorizer
104
104
  python -m ynab_amazon_categorizer
105
105
  ```
106
106
 
107
+ ### Dry run
108
+
109
+ Use `--dry-run` to walk through the full interactive flow and preview every
110
+ update without sending any changes to YNAB:
111
+
112
+ ```bash
113
+ ynab-amazon-categorizer --dry-run
114
+ ```
115
+
116
+ The tool still shows the JSON preview for each transaction but skips the API
117
+ call, so it is safe for trying the tool out or verifying matches.
118
+
119
+ ### Batch mode
120
+
121
+ Use `--batch` to run non-interactively: for every transaction with a single
122
+ high-confidence order match (unique exact-amount match within ~7 days), the
123
+ tool sets the memo (items + order link) automatically and **leaves the category
124
+ unchanged**, so you can still review/categorize later. Transactions with no
125
+ match or an ambiguous match are skipped.
126
+
127
+ ```bash
128
+ # Preview what batch mode would enrich
129
+ ynab-amazon-categorizer --batch --dry-run
130
+
131
+ # Apply memo enrichment
132
+ ynab-amazon-categorizer --batch
133
+ ```
134
+
135
+ You still paste the Amazon orders page once when prompted; `--batch` only
136
+ removes the per-transaction prompting.
137
+
107
138
  ### Workflow
108
139
  1. **Provide Amazon Orders Data** (optional but recommended):
109
140
  - Copy your Amazon orders page content
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.2.3'
22
- __version_tuple__ = version_tuple = (2, 2, 3)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gb865ea2d2'
24
+ __commit_id__ = commit_id = 'gfc90aac23'
@@ -49,14 +49,14 @@ class AmazonParser:
49
49
  orders = []
50
50
 
51
51
  # Find all order blocks using regex
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})"
52
+ order_pattern = r"Order placed\s*([A-Za-z]+ \d+, \d{4})\s*Total\s*\$([0-9][0-9,]*(?:\.[0-9]{1,2})?)\s*.*?Order # (\d{3}-\d{7}-\d{7})"
53
53
  order_matches = list(
54
54
  re.finditer(order_pattern, orders_text, re.DOTALL | re.IGNORECASE)
55
55
  )
56
56
 
57
57
  for idx, match in enumerate(order_matches):
58
58
  order_date = match.group(1).strip()
59
- order_total = float(match.group(2))
59
+ order_total = float(match.group(2).replace(",", ""))
60
60
  order_id = match.group(3)
61
61
 
62
62
  # Find the content after this order until the next order or end
@@ -112,7 +112,9 @@ class AmazonParser:
112
112
  skip_patterns = [
113
113
  r"^(Buy it again|Track package|View|Return|Write|Get|Share|Leave|Ask)",
114
114
  r"^(Delivered|Arriving|Now arriving|Auto-delivered|Package was)",
115
- r"^(Return items:|Return or replace)",
115
+ r"^(Return items:|Return or replace|Refund issued|Refund:|Returned)",
116
+ r"^(Subscribe & Save|Subscribe now|Skip this delivery|Deliver every"
117
+ r"|Change delivery|Manage subscription|Edit delivery|Set up now)",
116
118
  r"^\d+\.?\d* out of \d+ stars",
117
119
  r"^FREE|^Today by|^Get it|^List:|^Was:|^Limited-time deal",
118
120
  r"^\$\d+\.\d+|\(\$\d+\.\d+",
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  import copy
2
3
  import json
3
4
  import logging
@@ -90,6 +91,18 @@ def get_multiline_input_with_custom_submit(
90
91
  return None
91
92
 
92
93
 
94
+ def _prompt_line(message: str) -> str:
95
+ """Read one line of input via prompt_toolkit for consistent UX.
96
+
97
+ Used in place of the builtin input function so every prompt in the tool goes
98
+ through prompt_toolkit (uniform rendering and key handling). Mirrors builtin
99
+ input semantics: returns the entered text and lets ``EOFError`` /
100
+ ``KeyboardInterrupt`` propagate to the caller, so existing ``.strip()`` /
101
+ ``.lower()`` chains on the result keep working.
102
+ """
103
+ return prompt(message)
104
+
105
+
93
106
  def generate_split_summary_memo(matching_order: Order) -> str:
94
107
  """Generate a summary memo for split transactions showing all items"""
95
108
  if (
@@ -118,13 +131,15 @@ def prompt_for_item_details() -> dict[str, str | int | float | list[str] | None]
118
131
  item_details: dict[str, str | int | float | list[str] | None] = {}
119
132
 
120
133
  # Get item title/description
121
- title = input("Enter item title/description (optional): ").strip()
134
+ title = _prompt_line("Enter item title/description (optional): ").strip()
122
135
  if title:
123
136
  item_details["title"] = title
124
137
 
125
138
  # Get quantity
126
139
  while True:
127
- qty_input = input("Enter quantity (optional, press Enter to skip): ").strip()
140
+ qty_input = _prompt_line(
141
+ "Enter quantity (optional, press Enter to skip): "
142
+ ).strip()
128
143
  if not qty_input:
129
144
  break
130
145
  try:
@@ -139,7 +154,7 @@ def prompt_for_item_details() -> dict[str, str | int | float | list[str] | None]
139
154
 
140
155
  # Get price per item
141
156
  while True:
142
- price_input = input(
157
+ price_input = _prompt_line(
143
158
  "Enter item price (optional, press Enter to skip): "
144
159
  ).strip()
145
160
  if not price_input:
@@ -248,6 +263,37 @@ def build_single_payload(
248
263
  }
249
264
 
250
265
 
266
+ def build_memo_only_payload(transaction: dict[str, Any], memo: str) -> dict[str, Any]:
267
+ """Build an update payload that changes only the memo.
268
+
269
+ Used by batch mode: the category and approved status are preserved exactly
270
+ as YNAB returned them, so the transaction stays uncategorized for later
271
+ review while gaining the enriched Amazon memo.
272
+ """
273
+ return {
274
+ "id": transaction["id"],
275
+ "account_id": transaction["account_id"],
276
+ "date": transaction["date"],
277
+ "amount": transaction["amount"],
278
+ "payee_id": transaction.get("payee_id"),
279
+ "payee_name": transaction.get("payee_name", "N/A"),
280
+ "category_id": transaction.get("category_id"),
281
+ "memo": sanitize_memo(memo),
282
+ "cleared": transaction.get("cleared"),
283
+ "approved": transaction.get("approved", False),
284
+ "flag_color": transaction.get("flag_color"),
285
+ "import_id": transaction.get("import_id"),
286
+ }
287
+
288
+
289
+ def build_batch_memo(order: Order, memo_generator: MemoGenerator) -> str:
290
+ """Build the auto-enrich memo (item summary + order link) for batch mode."""
291
+ items_text = generate_split_summary_memo(order) or "Amazon Purchase"
292
+ order_link = memo_generator.generate_amazon_order_link(order.order_id)
293
+ raw = f"{items_text}\n {order_link}" if order_link else items_text
294
+ return sanitize_memo(raw)
295
+
296
+
251
297
  def build_split_payload(
252
298
  transaction: dict[str, Any],
253
299
  subtransactions: list[dict[str, int | str | None]],
@@ -416,7 +462,7 @@ def resolve_memo(
416
462
  }
417
463
  else:
418
464
  # Ask if user wants to enter item details manually
419
- manual_entry = input(
465
+ manual_entry = _prompt_line(
420
466
  "No order match found. Enter item details manually? (y/n, default n): "
421
467
  ).lower()
422
468
  if manual_entry == "y":
@@ -427,12 +473,9 @@ def resolve_memo(
427
473
  if item_details:
428
474
  if isinstance(item_details, dict) and "items" in item_details:
429
475
  # Auto-matched order data - format as: Item Name\n Order Link
430
- items_list = item_details["items"]
431
476
  items_text = (
432
- items_list[0]
433
- if isinstance(items_list, list) and items_list
434
- else "Amazon Purchase"
435
- )
477
+ generate_split_summary_memo(matching_order) if matching_order else ""
478
+ ) or "Amazon Purchase"
436
479
  order_id_value = item_details["order_id"]
437
480
  order_link = memo_generator.generate_amazon_order_link(
438
481
  order_id_value if isinstance(order_id_value, str) else None
@@ -450,7 +493,7 @@ def resolve_memo(
450
493
  if enhanced_memo and enhanced_memo != original_memo:
451
494
  print("\nSuggested memo:")
452
495
  print(f"'{enhanced_memo}'")
453
- use_suggested = input("Use suggested memo? (y/n, default y): ").lower()
496
+ use_suggested = _prompt_line("Use suggested memo? (y/n, default y): ").lower()
454
497
  if use_suggested != "n":
455
498
  return sanitize_memo(enhanced_memo)
456
499
  else:
@@ -506,7 +549,7 @@ def handle_split(
506
549
  while True:
507
550
  try:
508
551
  max_amount = abs(remaining_milliunits / 1000.0)
509
- amount_str = input(
552
+ amount_str = _prompt_line(
510
553
  f"Enter amount for '{category_name}' (positive, max {max_amount:.2f}, default {max_amount:.2f}): "
511
554
  )
512
555
  if not amount_str:
@@ -578,7 +621,7 @@ def _resolve_split_memo(
578
621
  else:
579
622
  suggested_split_memo = "Additional item"
580
623
  else:
581
- manual_entry = input(
624
+ manual_entry = _prompt_line(
582
625
  "Enter item details for this split? (y/n, default n): "
583
626
  ).lower()
584
627
  if manual_entry == "y":
@@ -591,7 +634,7 @@ def _resolve_split_memo(
591
634
  if suggested_split_memo:
592
635
  print(f"Suggested memo for '{category_name}' split:")
593
636
  print(f"'{suggested_split_memo}'")
594
- use_suggested = input("Use suggested memo? (y/n, default y): ").lower()
637
+ use_suggested = _prompt_line("Use suggested memo? (y/n, default y): ").lower()
595
638
  if use_suggested != "n":
596
639
  return suggested_split_memo
597
640
  else:
@@ -614,10 +657,17 @@ def process_transaction(
614
657
  category_completer: CategoryCompleter,
615
658
  category_name_map: dict[str, str],
616
659
  category_id_map: dict[str, str],
660
+ used_order_ids: set[str] | None = None,
661
+ dry_run: bool = False,
617
662
  ) -> bool:
618
663
  """Process a single transaction through the interactive flow.
619
664
 
620
665
  Returns True if processed/skipped, False if user quit.
666
+
667
+ ``used_order_ids`` accumulates the order IDs already applied to a
668
+ transaction so the matcher does not reuse one order for several
669
+ same-amount transactions. When ``dry_run`` is True no changes are sent
670
+ to YNAB and matched orders are not marked as used.
621
671
  """
622
672
  transaction_id = transaction["id"]
623
673
  date = transaction["date"]
@@ -628,7 +678,7 @@ def process_transaction(
628
678
 
629
679
  if amount_milliunits > 0:
630
680
  print(f"Found inflow transaction: {payee} ${amount_float:.2f}")
631
- process_inflow = input(
681
+ process_inflow = _prompt_line(
632
682
  "Process this inflow (refund/credit)? (y/n, default n): "
633
683
  ).lower()
634
684
  if process_inflow != "y":
@@ -639,7 +689,7 @@ def process_transaction(
639
689
  print(f" ID: {transaction_id}")
640
690
  print(f" Date: {date}")
641
691
  print(f" Payee: {payee}")
642
- print(f" Amount: {-amount_float:.2f}")
692
+ print(f" Amount: {amount_float:.2f}")
643
693
  if original_memo:
644
694
  print(f" Original Memo: {original_memo}")
645
695
 
@@ -648,7 +698,7 @@ def process_transaction(
648
698
  if parsed_orders:
649
699
  transaction_matcher = TransactionMatcher()
650
700
  matching_order = transaction_matcher.find_matching_order(
651
- amount_float, date, parsed_orders
701
+ amount_float, date, parsed_orders, used_order_ids
652
702
  )
653
703
  if matching_order:
654
704
  display_matched_order(matching_order, memo_generator)
@@ -656,7 +706,7 @@ def process_transaction(
656
706
  print(" ⚠ No matching order found in parsed Amazon data")
657
707
 
658
708
  while True: # Action loop (c, s, q)
659
- action = input(
709
+ action = _prompt_line(
660
710
  "Action? (c = categorize/split, s = skip, q = quit, default c): "
661
711
  ).lower()
662
712
  if not action:
@@ -677,8 +727,19 @@ def process_transaction(
677
727
  category_completer,
678
728
  category_name_map,
679
729
  category_id_map,
730
+ dry_run,
680
731
  )
681
732
  if result == "done":
733
+ # Mark the matched order as consumed so it is not reused for a
734
+ # later transaction of the same amount. Skip in dry-run because
735
+ # nothing was actually applied.
736
+ if (
737
+ not dry_run
738
+ and used_order_ids is not None
739
+ and matching_order is not None
740
+ and matching_order.order_id is not None
741
+ ):
742
+ used_order_ids.add(matching_order.order_id)
682
743
  return True
683
744
  # result == "continue" means back to action prompt
684
745
  continue
@@ -695,11 +756,14 @@ def _handle_categorize(
695
756
  category_completer: CategoryCompleter,
696
757
  category_name_map: dict[str, str],
697
758
  category_id_map: dict[str, str],
759
+ dry_run: bool = False,
698
760
  ) -> str:
699
761
  """Handle the categorize action for a transaction.
700
762
 
701
763
  Returns "done" if the transaction was successfully updated (or split completed),
702
764
  or "continue" to go back to the action prompt.
765
+
766
+ When ``dry_run`` is True the preview is shown but no update is sent to YNAB.
703
767
  """
704
768
  transaction_id = transaction["id"]
705
769
  updated_payload_dict: dict[str, Any] | None = None
@@ -712,7 +776,7 @@ def _handle_categorize(
712
776
  if should_offer_split:
713
777
  print("There is more than one item in this transaction.")
714
778
 
715
- split_decision = input("Split this transaction? (y/n, default n): ").lower()
779
+ split_decision = _prompt_line("Split this transaction? (y/n, default n): ").lower()
716
780
 
717
781
  if split_decision != "y":
718
782
  # --- SINGLE CATEGORY ---
@@ -751,7 +815,10 @@ def _handle_categorize(
751
815
  print("\n--- Preview Update ---")
752
816
  preview_dict = build_preview(updated_payload_dict, category_id_map)
753
817
  print(json.dumps(preview_dict, indent=2, ensure_ascii=False))
754
- confirm = input("Confirm update? (y/n, default y): ").lower()
818
+ if dry_run:
819
+ print("[dry-run] No changes were sent to YNAB.")
820
+ return "done"
821
+ confirm = _prompt_line("Confirm update? (y/n, default y): ").lower()
755
822
  if not confirm:
756
823
  confirm = "y"
757
824
  if confirm == "y":
@@ -770,13 +837,102 @@ def _handle_categorize(
770
837
  return "continue"
771
838
 
772
839
 
840
+ # --- Batch Mode ---
841
+
842
+
843
+ def process_batch(
844
+ transactions: list[dict[str, Any]],
845
+ parsed_orders: list[Order] | None,
846
+ memo_generator: MemoGenerator,
847
+ ynab_client: YNABClient,
848
+ dry_run: bool = False,
849
+ ) -> tuple[int, int, int]:
850
+ """Auto-enrich memos for confidently-matched transactions without prompting.
851
+
852
+ Only the memo is set (items + order link); the category is left untouched so
853
+ the transaction stays uncategorized for later review. Transactions with no
854
+ match or an ambiguous match are skipped.
855
+
856
+ Returns a ``(enriched, skipped, failed)`` tuple.
857
+ """
858
+ matcher = TransactionMatcher()
859
+ used_order_ids: set[str] = set()
860
+ enriched = skipped = failed = 0
861
+
862
+ for t in transactions:
863
+ amount_float = t["amount"] / 1000.0
864
+ order = matcher.find_confident_match(
865
+ amount_float, t["date"], parsed_orders or [], used_order_ids
866
+ )
867
+ if order is None:
868
+ skipped += 1
869
+ continue
870
+
871
+ memo = build_batch_memo(order, memo_generator)
872
+ payload = build_memo_only_payload(t, memo)
873
+ payee = t.get("payee_name", "N/A")
874
+ summary = memo.splitlines()[0] if memo else ""
875
+
876
+ if dry_run:
877
+ print(f" [dry-run] would enrich {payee} ${amount_float:.2f}: {summary}")
878
+ if order.order_id:
879
+ used_order_ids.add(order.order_id)
880
+ enriched += 1
881
+ continue
882
+
883
+ try:
884
+ ynab_client.update_transaction(t["id"], payload)
885
+ print(f" ✓ Enriched {payee} ${amount_float:.2f}: {summary}")
886
+ if order.order_id:
887
+ used_order_ids.add(order.order_id)
888
+ enriched += 1
889
+ except (YNABAPIError, requests.exceptions.RequestException) as exc:
890
+ logger.error("Failed to enrich transaction %s: %s", t["id"], exc)
891
+ print(f" ✗ Failed to enrich {payee} ${amount_float:.2f}: {exc}")
892
+ failed += 1
893
+
894
+ return enriched, skipped, failed
895
+
896
+
773
897
  # --- Main Script Logic ---
774
898
 
775
899
 
776
- def main() -> None:
900
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
901
+ """Parse command-line arguments."""
902
+ parser = argparse.ArgumentParser(
903
+ prog="ynab-amazon-categorizer",
904
+ description=(
905
+ "Auto-categorize Amazon transactions in YNAB with item-level "
906
+ "memos and category suggestions."
907
+ ),
908
+ )
909
+ parser.add_argument(
910
+ "--dry-run",
911
+ action="store_true",
912
+ help="Preview updates without sending any changes to YNAB.",
913
+ )
914
+ parser.add_argument(
915
+ "--batch",
916
+ action="store_true",
917
+ help=(
918
+ "Non-interactive: auto-set memos (items + order link) for "
919
+ "confidently matched transactions and leave categories unchanged. "
920
+ "Combine with --dry-run to preview."
921
+ ),
922
+ )
923
+ return parser.parse_args(argv)
924
+
925
+
926
+ def main(argv: list[str] | None = None) -> None:
777
927
  """Main CLI function."""
928
+ args = _parse_args(argv)
929
+ dry_run = args.dry_run
930
+
778
931
  logging.basicConfig(level=logging.INFO)
779
932
 
933
+ if dry_run:
934
+ print("*** DRY RUN: no changes will be sent to YNAB. ***")
935
+
780
936
  # Load configuration using extracted Config class
781
937
  try:
782
938
  config = Config.from_env()
@@ -813,7 +969,7 @@ def main() -> None:
813
969
  print(
814
970
  "You can paste Amazon orders page content to automatically match transactions with order details."
815
971
  )
816
- provide_orders = input(
972
+ provide_orders = _prompt_line(
817
973
  "Would you like to provide Amazon orders data? (y/n, default y): "
818
974
  ).lower()
819
975
  if not provide_orders:
@@ -845,7 +1001,24 @@ def main() -> None:
845
1001
  f"\nFound {len(transactions_to_process)} uncategorized Amazon transaction(s) needing attention."
846
1002
  )
847
1003
 
1004
+ # --- Batch Mode (non-interactive memo enrichment) ---
1005
+ if args.batch:
1006
+ print("\n--- Batch: auto-enriching memos for confident matches ---")
1007
+ enriched, skipped, failed = process_batch(
1008
+ transactions_to_process,
1009
+ parsed_orders,
1010
+ memo_generator,
1011
+ ynab_client,
1012
+ dry_run,
1013
+ )
1014
+ print(
1015
+ f"\nBatch complete: {enriched} enriched, {skipped} skipped "
1016
+ f"(no/ambiguous match), {failed} failed."
1017
+ )
1018
+ return
1019
+
848
1020
  # --- Process Transactions (Main Loop) ---
1021
+ used_order_ids: set[str] = set()
849
1022
  for i, t in enumerate(transactions_to_process):
850
1023
  should_continue = process_transaction(
851
1024
  t,
@@ -857,6 +1030,8 @@ def main() -> None:
857
1030
  category_completer_instance,
858
1031
  category_name_map,
859
1032
  category_id_map,
1033
+ used_order_ids,
1034
+ dry_run,
860
1035
  )
861
1036
  if not should_continue:
862
1037
  sys.exit(0)