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