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.
- ynab_amazon_categorizer-2.3.0/.github/workflows/ci.yml +65 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.gitignore +9 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/CHANGELOG.md +29 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/DEVELOPMENT.md +1 -1
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/PKG-INFO +33 -2
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/README.md +32 -1
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/_version.py +3 -3
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/amazon_parser.py +5 -3
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/cli.py +196 -21
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/transaction_matcher.py +63 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +33 -2
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +1 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_amazon_parser.py +16 -0
- ynab_amazon_categorizer-2.3.0/tests/test_amazon_parser_real.py +175 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_cli_functions.py +351 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_transaction_matcher.py +85 -0
- ynab_amazon_categorizer-2.2.3/tests/test_amazon_parser_real.py +0 -49
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.env.example +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.git_archival.txt +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.gitattributes +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.github/workflows/release.yml +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/.pre-commit-config.yaml +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/AGENTS.md +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/CLAUDE.md +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/LICENSE +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/pyproject.toml +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/setup.cfg +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/__main__.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/config.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/exceptions.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer/ynab_client.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_config.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.3 → ynab_amazon_categorizer-2.3.0}/tests/test_ynab_client.py +0 -0
- {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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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+
|
|
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+
|
|
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.
|
|
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'
|
|
@@ -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*\$(
|
|
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+",
|
{ynab_amazon_categorizer-2.2.3 → 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":
|
|
@@ -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
|
-
|
|
433
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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: {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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)
|