ynab-amazon-categorizer 2.2.4__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.4 → ynab_amazon_categorizer-2.3.0}/.gitignore +9 -0
  3. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/CHANGELOG.md +17 -0
  4. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/PKG-INFO +32 -1
  5. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/README.md +31 -0
  6. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/_version.py +3 -3
  7. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/amazon_parser.py +3 -1
  8. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/cli.py +193 -15
  9. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/transaction_matcher.py +63 -0
  10. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +32 -1
  11. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +1 -0
  12. ynab_amazon_categorizer-2.3.0/tests/test_amazon_parser_real.py +175 -0
  13. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_cli_functions.py +298 -2
  14. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_transaction_matcher.py +85 -0
  15. ynab_amazon_categorizer-2.2.4/tests/test_amazon_parser_real.py +0 -49
  16. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.env.example +0 -0
  17. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.git_archival.txt +0 -0
  18. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.gitattributes +0 -0
  19. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.github/workflows/release.yml +0 -0
  20. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.pre-commit-config.yaml +0 -0
  21. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/AGENTS.md +0 -0
  22. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/CLAUDE.md +0 -0
  23. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/DEVELOPMENT.md +0 -0
  24. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/LICENSE +0 -0
  25. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/pyproject.toml +0 -0
  26. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/setup.cfg +0 -0
  27. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__init__.py +0 -0
  28. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__main__.py +0 -0
  29. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/config.py +0 -0
  30. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/exceptions.py +0 -0
  31. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
  32. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/ynab_client.py +0 -0
  33. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
  34. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
  35. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
  36. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
  37. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/__init__.py +0 -0
  38. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_amazon_parser.py +0 -0
  39. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_config.py +0 -0
  40. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_memo_generator.py +0 -0
  41. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_ynab_client.py +0 -0
  42. {ynab_amazon_categorizer-2.2.4 → 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,23 @@ 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
+
10
27
  ## [2.2.4] - 2026-05-13
11
28
 
12
29
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ynab-amazon-categorizer
3
- Version: 2.2.4
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
@@ -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
@@ -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.4'
22
- __version_tuple__ = version_tuple = (2, 2, 4)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g034b5e27c'
24
+ __commit_id__ = commit_id = 'gfc90aac23'
@@ -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":
@@ -447,7 +493,7 @@ def resolve_memo(
447
493
  if enhanced_memo and enhanced_memo != original_memo:
448
494
  print("\nSuggested memo:")
449
495
  print(f"'{enhanced_memo}'")
450
- use_suggested = input("Use suggested memo? (y/n, default y): ").lower()
496
+ use_suggested = _prompt_line("Use suggested memo? (y/n, default y): ").lower()
451
497
  if use_suggested != "n":
452
498
  return sanitize_memo(enhanced_memo)
453
499
  else:
@@ -503,7 +549,7 @@ def handle_split(
503
549
  while True:
504
550
  try:
505
551
  max_amount = abs(remaining_milliunits / 1000.0)
506
- amount_str = input(
552
+ amount_str = _prompt_line(
507
553
  f"Enter amount for '{category_name}' (positive, max {max_amount:.2f}, default {max_amount:.2f}): "
508
554
  )
509
555
  if not amount_str:
@@ -575,7 +621,7 @@ def _resolve_split_memo(
575
621
  else:
576
622
  suggested_split_memo = "Additional item"
577
623
  else:
578
- manual_entry = input(
624
+ manual_entry = _prompt_line(
579
625
  "Enter item details for this split? (y/n, default n): "
580
626
  ).lower()
581
627
  if manual_entry == "y":
@@ -588,7 +634,7 @@ def _resolve_split_memo(
588
634
  if suggested_split_memo:
589
635
  print(f"Suggested memo for '{category_name}' split:")
590
636
  print(f"'{suggested_split_memo}'")
591
- use_suggested = input("Use suggested memo? (y/n, default y): ").lower()
637
+ use_suggested = _prompt_line("Use suggested memo? (y/n, default y): ").lower()
592
638
  if use_suggested != "n":
593
639
  return suggested_split_memo
594
640
  else:
@@ -611,10 +657,17 @@ def process_transaction(
611
657
  category_completer: CategoryCompleter,
612
658
  category_name_map: dict[str, str],
613
659
  category_id_map: dict[str, str],
660
+ used_order_ids: set[str] | None = None,
661
+ dry_run: bool = False,
614
662
  ) -> bool:
615
663
  """Process a single transaction through the interactive flow.
616
664
 
617
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.
618
671
  """
619
672
  transaction_id = transaction["id"]
620
673
  date = transaction["date"]
@@ -625,7 +678,7 @@ def process_transaction(
625
678
 
626
679
  if amount_milliunits > 0:
627
680
  print(f"Found inflow transaction: {payee} ${amount_float:.2f}")
628
- process_inflow = input(
681
+ process_inflow = _prompt_line(
629
682
  "Process this inflow (refund/credit)? (y/n, default n): "
630
683
  ).lower()
631
684
  if process_inflow != "y":
@@ -645,7 +698,7 @@ def process_transaction(
645
698
  if parsed_orders:
646
699
  transaction_matcher = TransactionMatcher()
647
700
  matching_order = transaction_matcher.find_matching_order(
648
- amount_float, date, parsed_orders
701
+ amount_float, date, parsed_orders, used_order_ids
649
702
  )
650
703
  if matching_order:
651
704
  display_matched_order(matching_order, memo_generator)
@@ -653,7 +706,7 @@ def process_transaction(
653
706
  print(" ⚠ No matching order found in parsed Amazon data")
654
707
 
655
708
  while True: # Action loop (c, s, q)
656
- action = input(
709
+ action = _prompt_line(
657
710
  "Action? (c = categorize/split, s = skip, q = quit, default c): "
658
711
  ).lower()
659
712
  if not action:
@@ -674,8 +727,19 @@ def process_transaction(
674
727
  category_completer,
675
728
  category_name_map,
676
729
  category_id_map,
730
+ dry_run,
677
731
  )
678
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)
679
743
  return True
680
744
  # result == "continue" means back to action prompt
681
745
  continue
@@ -692,11 +756,14 @@ def _handle_categorize(
692
756
  category_completer: CategoryCompleter,
693
757
  category_name_map: dict[str, str],
694
758
  category_id_map: dict[str, str],
759
+ dry_run: bool = False,
695
760
  ) -> str:
696
761
  """Handle the categorize action for a transaction.
697
762
 
698
763
  Returns "done" if the transaction was successfully updated (or split completed),
699
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.
700
767
  """
701
768
  transaction_id = transaction["id"]
702
769
  updated_payload_dict: dict[str, Any] | None = None
@@ -709,7 +776,7 @@ def _handle_categorize(
709
776
  if should_offer_split:
710
777
  print("There is more than one item in this transaction.")
711
778
 
712
- split_decision = input("Split this transaction? (y/n, default n): ").lower()
779
+ split_decision = _prompt_line("Split this transaction? (y/n, default n): ").lower()
713
780
 
714
781
  if split_decision != "y":
715
782
  # --- SINGLE CATEGORY ---
@@ -748,7 +815,10 @@ def _handle_categorize(
748
815
  print("\n--- Preview Update ---")
749
816
  preview_dict = build_preview(updated_payload_dict, category_id_map)
750
817
  print(json.dumps(preview_dict, indent=2, ensure_ascii=False))
751
- 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()
752
822
  if not confirm:
753
823
  confirm = "y"
754
824
  if confirm == "y":
@@ -767,13 +837,102 @@ def _handle_categorize(
767
837
  return "continue"
768
838
 
769
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
+
770
897
  # --- Main Script Logic ---
771
898
 
772
899
 
773
- 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:
774
927
  """Main CLI function."""
928
+ args = _parse_args(argv)
929
+ dry_run = args.dry_run
930
+
775
931
  logging.basicConfig(level=logging.INFO)
776
932
 
933
+ if dry_run:
934
+ print("*** DRY RUN: no changes will be sent to YNAB. ***")
935
+
777
936
  # Load configuration using extracted Config class
778
937
  try:
779
938
  config = Config.from_env()
@@ -810,7 +969,7 @@ def main() -> None:
810
969
  print(
811
970
  "You can paste Amazon orders page content to automatically match transactions with order details."
812
971
  )
813
- provide_orders = input(
972
+ provide_orders = _prompt_line(
814
973
  "Would you like to provide Amazon orders data? (y/n, default y): "
815
974
  ).lower()
816
975
  if not provide_orders:
@@ -842,7 +1001,24 @@ def main() -> None:
842
1001
  f"\nFound {len(transactions_to_process)} uncategorized Amazon transaction(s) needing attention."
843
1002
  )
844
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
+
845
1020
  # --- Process Transactions (Main Loop) ---
1021
+ used_order_ids: set[str] = set()
846
1022
  for i, t in enumerate(transactions_to_process):
847
1023
  should_continue = process_transaction(
848
1024
  t,
@@ -854,6 +1030,8 @@ def main() -> None:
854
1030
  category_completer_instance,
855
1031
  category_name_map,
856
1032
  category_id_map,
1033
+ used_order_ids,
1034
+ dry_run,
857
1035
  )
858
1036
  if not should_continue:
859
1037
  sys.exit(0)
@@ -20,11 +20,15 @@ class TransactionMatcher:
20
20
  transaction_amount: float,
21
21
  transaction_date: str,
22
22
  parsed_orders: Sequence[Order],
23
+ used_order_ids: set[str] | None = None,
23
24
  ) -> Order | None:
24
25
  """Find the best matching order for a transaction.
25
26
 
26
27
  Matching requires an exact amount match (within 1 cent).
27
28
  Ties are broken by date proximity, then by order ID for determinism.
29
+
30
+ Orders whose ``order_id`` appears in ``used_order_ids`` are skipped so a
31
+ single order is not matched to multiple transactions of the same amount.
28
32
  """
29
33
  if not parsed_orders:
30
34
  return None
@@ -46,6 +50,13 @@ class TransactionMatcher:
46
50
  if order.total is None:
47
51
  continue
48
52
 
53
+ if (
54
+ used_order_ids
55
+ and order.order_id is not None
56
+ and order.order_id in used_order_ids
57
+ ):
58
+ continue
59
+
49
60
  amount_diff = abs(order.total - transaction_amount_abs)
50
61
  if amount_diff >= 0.01:
51
62
  continue
@@ -91,3 +102,55 @@ class TransactionMatcher:
91
102
  best_order_id = order_id
92
103
 
93
104
  return best_match
105
+
106
+ def find_confident_match(
107
+ self,
108
+ transaction_amount: float,
109
+ transaction_date: str,
110
+ parsed_orders: Sequence[Order],
111
+ used_order_ids: set[str] | None = None,
112
+ max_date_diff_days: int = 7,
113
+ ) -> Order | None:
114
+ """Return an order only when the match is unambiguous (for batch use).
115
+
116
+ Unlike ``find_matching_order``, this requires *exactly one* unused order
117
+ matching the amount (within 1 cent). If that order has a parseable date
118
+ it must be within ``max_date_diff_days`` of the transaction. Any
119
+ ambiguity (zero or multiple amount matches, or a far-off date) returns
120
+ ``None`` so batch mode never auto-applies a guess.
121
+ """
122
+ amount_abs = abs(transaction_amount)
123
+
124
+ try:
125
+ trans_date: datetime | None = datetime.strptime(
126
+ transaction_date, "%Y-%m-%d"
127
+ )
128
+ except (ValueError, TypeError):
129
+ trans_date = None
130
+
131
+ candidates: list[Order] = []
132
+ for order in parsed_orders:
133
+ if order.total is None:
134
+ continue
135
+ if (
136
+ used_order_ids
137
+ and order.order_id is not None
138
+ and order.order_id in used_order_ids
139
+ ):
140
+ continue
141
+ if abs(order.total - amount_abs) >= 0.01:
142
+ continue
143
+ candidates.append(order)
144
+
145
+ if len(candidates) != 1:
146
+ return None
147
+
148
+ order = candidates[0]
149
+ if trans_date and isinstance(order.date_str, str) and order.date_str:
150
+ try:
151
+ order_date = datetime.strptime(order.date_str, "%B %d, %Y")
152
+ if abs((trans_date - order_date).days) > max_date_diff_days:
153
+ return None
154
+ except (ValueError, TypeError):
155
+ pass
156
+ return order