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.
- ynab_amazon_categorizer-2.3.0/.github/workflows/ci.yml +65 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.gitignore +9 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/CHANGELOG.md +17 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/PKG-INFO +32 -1
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/README.md +31 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/_version.py +3 -3
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/amazon_parser.py +3 -1
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/cli.py +193 -15
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/transaction_matcher.py +63 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +32 -1
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +1 -0
- ynab_amazon_categorizer-2.3.0/tests/test_amazon_parser_real.py +175 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_cli_functions.py +298 -2
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_transaction_matcher.py +85 -0
- ynab_amazon_categorizer-2.2.4/tests/test_amazon_parser_real.py +0 -49
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.env.example +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.git_archival.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.gitattributes +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.github/workflows/release.yml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/.pre-commit-config.yaml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/AGENTS.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/CLAUDE.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/DEVELOPMENT.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/LICENSE +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/pyproject.toml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/setup.cfg +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__main__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/config.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/exceptions.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/ynab_client.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_amazon_parser.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_config.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/tests/test_ynab_client.py +0 -0
- {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
|
|
@@ -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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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+",
|
{ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/cli.py
RENAMED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|