moneyflow 0.7.0__py3-none-any.whl
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.
- moneyflow/__init__.py +28 -0
- moneyflow/__main__.py +6 -0
- moneyflow/account_manager.py +355 -0
- moneyflow/app.py +2270 -0
- moneyflow/app_controller.py +1673 -0
- moneyflow/backend_config.py +97 -0
- moneyflow/backends/__init__.py +107 -0
- moneyflow/backends/amazon.py +495 -0
- moneyflow/backends/base.py +256 -0
- moneyflow/backends/demo.py +192 -0
- moneyflow/backends/monarch.py +176 -0
- moneyflow/backends/ynab.py +205 -0
- moneyflow/cache_manager.py +218 -0
- moneyflow/categories.py +654 -0
- moneyflow/cli.py +464 -0
- moneyflow/commit_orchestrator.py +244 -0
- moneyflow/credentials.py +327 -0
- moneyflow/data_manager.py +1162 -0
- moneyflow/demo_data_generator.py +789 -0
- moneyflow/duplicate_detector.py +222 -0
- moneyflow/formatters.py +680 -0
- moneyflow/importers/__init__.py +0 -0
- moneyflow/importers/amazon_orders_csv.py +215 -0
- moneyflow/keybindings.py +110 -0
- moneyflow/logging_config.py +70 -0
- moneyflow/migration.py +291 -0
- moneyflow/modal_helper.py +242 -0
- moneyflow/monarchmoney.py +2986 -0
- moneyflow/notification_helper.py +240 -0
- moneyflow/retry_logic.py +94 -0
- moneyflow/screens/__init__.py +1 -0
- moneyflow/screens/account_name_input_screen.py +180 -0
- moneyflow/screens/account_selector_screen.py +333 -0
- moneyflow/screens/credential_screens.py +834 -0
- moneyflow/screens/duplicates_screen.py +439 -0
- moneyflow/screens/edit_screens.py +619 -0
- moneyflow/screens/review_screen.py +146 -0
- moneyflow/screens/search_screen.py +88 -0
- moneyflow/screens/transaction_detail_screen.py +139 -0
- moneyflow/state.py +1270 -0
- moneyflow/styles/moneyflow.tcss +113 -0
- moneyflow/textual_view.py +89 -0
- moneyflow/time_navigator.py +299 -0
- moneyflow/view_interface.py +80 -0
- moneyflow/widgets/__init__.py +5 -0
- moneyflow/widgets/help_screen.py +71 -0
- moneyflow/ynab_client.py +519 -0
- moneyflow-0.7.0.dist-info/METADATA +281 -0
- moneyflow-0.7.0.dist-info/RECORD +52 -0
- moneyflow-0.7.0.dist-info/WHEEL +4 -0
- moneyflow-0.7.0.dist-info/entry_points.txt +3 -0
- moneyflow-0.7.0.dist-info/licenses/LICENSE +21 -0
moneyflow/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Personal Finance Power User TUI
|
|
3
|
+
|
|
4
|
+
A terminal-based interface for fast transaction management.
|
|
5
|
+
Supports multiple finance platforms including Monarch Money.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .backends import DemoBackend, FinanceBackend, MonarchBackend, get_backend
|
|
11
|
+
from .data_manager import DataManager
|
|
12
|
+
from .duplicate_detector import DuplicateDetector
|
|
13
|
+
from .monarchmoney import MonarchMoney
|
|
14
|
+
from .state import AppState, SortMode, TransactionEdit, ViewMode
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MonarchMoney",
|
|
18
|
+
"FinanceBackend",
|
|
19
|
+
"MonarchBackend",
|
|
20
|
+
"DemoBackend",
|
|
21
|
+
"get_backend",
|
|
22
|
+
"DataManager",
|
|
23
|
+
"AppState",
|
|
24
|
+
"ViewMode",
|
|
25
|
+
"SortMode",
|
|
26
|
+
"TransactionEdit",
|
|
27
|
+
"DuplicateDetector",
|
|
28
|
+
]
|
moneyflow/__main__.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-account management for supporting multiple backend accounts.
|
|
3
|
+
|
|
4
|
+
This module provides infrastructure for managing multiple accounts (e.g., multiple
|
|
5
|
+
Monarch Money accounts, YNAB budgets, etc.) with isolated credentials and data.
|
|
6
|
+
|
|
7
|
+
Each account gets its own profile directory:
|
|
8
|
+
~/.moneyflow/profiles/{account_id}/
|
|
9
|
+
├── credentials.enc # Encrypted credentials (if backend requires auth)
|
|
10
|
+
├── salt # Salt for credential encryption
|
|
11
|
+
├── merchants.json # Merchant cache
|
|
12
|
+
└── cache/ # Transaction cache directory
|
|
13
|
+
|
|
14
|
+
Account metadata is stored in ~/.moneyflow/accounts.json
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from dataclasses import field as dataclass_field
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Literal, Optional
|
|
24
|
+
|
|
25
|
+
BackendType = Literal["monarch", "ynab", "amazon", "demo"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Account:
|
|
30
|
+
"""
|
|
31
|
+
Represents a configured account/backend connection.
|
|
32
|
+
|
|
33
|
+
Each account has isolated credentials, cache, and merchant data.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
id: str # Unique identifier (e.g., "monarch-personal", "ynab-2025")
|
|
37
|
+
name: str # User-friendly display name (e.g., "Monarch - Personal")
|
|
38
|
+
backend_type: BackendType # Backend type (monarch, ynab, amazon, demo)
|
|
39
|
+
created_at: str # ISO timestamp when account was created
|
|
40
|
+
last_used: Optional[str] = None # ISO timestamp when last accessed
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict:
|
|
43
|
+
"""Convert to dictionary for JSON serialization."""
|
|
44
|
+
return {
|
|
45
|
+
"id": self.id,
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"backend_type": self.backend_type,
|
|
48
|
+
"created_at": self.created_at,
|
|
49
|
+
"last_used": self.last_used,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def from_dict(data: Dict) -> "Account":
|
|
54
|
+
"""Create Account from dictionary."""
|
|
55
|
+
return Account(
|
|
56
|
+
id=data["id"],
|
|
57
|
+
name=data["name"],
|
|
58
|
+
backend_type=data["backend_type"],
|
|
59
|
+
created_at=data["created_at"],
|
|
60
|
+
last_used=data.get("last_used"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AccountRegistry:
|
|
66
|
+
"""
|
|
67
|
+
Manages the list of configured accounts and active account selection.
|
|
68
|
+
|
|
69
|
+
Stored in ~/.moneyflow/accounts.json
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
accounts: List[Account] = dataclass_field(default_factory=list)
|
|
73
|
+
last_active_account: Optional[str] = None # Account ID
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> Dict:
|
|
76
|
+
"""Convert to dictionary for JSON serialization."""
|
|
77
|
+
return {
|
|
78
|
+
"accounts": [acc.to_dict() for acc in self.accounts],
|
|
79
|
+
"last_active_account": self.last_active_account,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def from_dict(data: Dict) -> "AccountRegistry":
|
|
84
|
+
"""Create AccountRegistry from dictionary."""
|
|
85
|
+
return AccountRegistry(
|
|
86
|
+
accounts=[Account.from_dict(acc) for acc in data.get("accounts", [])],
|
|
87
|
+
last_active_account=data.get("last_active_account"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AccountManager:
|
|
92
|
+
"""
|
|
93
|
+
Manages account profiles and their associated storage.
|
|
94
|
+
|
|
95
|
+
Each account gets isolated storage in ~/.moneyflow/profiles/{account_id}/
|
|
96
|
+
Account metadata is tracked in ~/.moneyflow/accounts.json
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
100
|
+
"""
|
|
101
|
+
Initialize account manager.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
config_dir: Optional custom config directory (defaults to ~/.moneyflow)
|
|
105
|
+
"""
|
|
106
|
+
if config_dir is None:
|
|
107
|
+
config_dir = Path.home() / ".moneyflow"
|
|
108
|
+
|
|
109
|
+
self.config_dir = Path(config_dir)
|
|
110
|
+
self.profiles_dir = self.config_dir / "profiles"
|
|
111
|
+
self.accounts_file = self.config_dir / "accounts.json"
|
|
112
|
+
|
|
113
|
+
# Create directories if they don't exist
|
|
114
|
+
self.config_dir.mkdir(mode=0o700, exist_ok=True)
|
|
115
|
+
self.profiles_dir.mkdir(mode=0o700, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
def load_registry(self) -> AccountRegistry:
|
|
118
|
+
"""
|
|
119
|
+
Load account registry from disk.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
AccountRegistry with all configured accounts
|
|
123
|
+
"""
|
|
124
|
+
if not self.accounts_file.exists():
|
|
125
|
+
return AccountRegistry()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with open(self.accounts_file, "r") as f:
|
|
129
|
+
data = json.load(f)
|
|
130
|
+
return AccountRegistry.from_dict(data)
|
|
131
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
132
|
+
# Corrupt registry - start fresh but log warning
|
|
133
|
+
import logging
|
|
134
|
+
|
|
135
|
+
logging.warning(f"Corrupt accounts.json, starting fresh: {e}")
|
|
136
|
+
return AccountRegistry()
|
|
137
|
+
|
|
138
|
+
def save_registry(self, registry: AccountRegistry) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Save account registry to disk.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
registry: AccountRegistry to save
|
|
144
|
+
"""
|
|
145
|
+
with open(self.accounts_file, "w") as f:
|
|
146
|
+
json.dump(registry.to_dict(), f, indent=2)
|
|
147
|
+
|
|
148
|
+
# Ensure only user can read
|
|
149
|
+
self.accounts_file.chmod(0o600)
|
|
150
|
+
|
|
151
|
+
def generate_account_id(self, backend_type: str, account_name: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Generate unique account ID from backend type and account name.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
backend_type: Backend type (monarch, ynab, etc.)
|
|
157
|
+
account_name: User-provided account name
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Account ID (e.g., "monarch-personal", "ynab-budget-2025")
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> AccountManager.generate_account_id("monarch", "Personal")
|
|
164
|
+
'monarch-personal'
|
|
165
|
+
>>> AccountManager.generate_account_id("ynab", "Budget 2025")
|
|
166
|
+
'ynab-budget-2025'
|
|
167
|
+
"""
|
|
168
|
+
# Normalize account name to lowercase, replace spaces/special chars with hyphens
|
|
169
|
+
normalized = account_name.lower()
|
|
170
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
|
171
|
+
normalized = normalized.strip("-")
|
|
172
|
+
|
|
173
|
+
# Combine backend type + normalized name
|
|
174
|
+
account_id = f"{backend_type}-{normalized}"
|
|
175
|
+
|
|
176
|
+
# Ensure uniqueness by appending number if needed
|
|
177
|
+
registry = self.load_registry()
|
|
178
|
+
existing_ids = {acc.id for acc in registry.accounts}
|
|
179
|
+
|
|
180
|
+
if account_id not in existing_ids:
|
|
181
|
+
return account_id
|
|
182
|
+
|
|
183
|
+
# Append number to make unique
|
|
184
|
+
counter = 2
|
|
185
|
+
while f"{account_id}-{counter}" in existing_ids:
|
|
186
|
+
counter += 1
|
|
187
|
+
|
|
188
|
+
return f"{account_id}-{counter}"
|
|
189
|
+
|
|
190
|
+
def create_account(
|
|
191
|
+
self, name: str, backend_type: BackendType, account_id: Optional[str] = None
|
|
192
|
+
) -> Account:
|
|
193
|
+
"""
|
|
194
|
+
Create a new account profile.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: User-friendly display name
|
|
198
|
+
backend_type: Backend type (monarch, ynab, amazon, demo)
|
|
199
|
+
account_id: Optional custom ID (generated if not provided)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Created Account object
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ValueError: If account ID already exists
|
|
206
|
+
"""
|
|
207
|
+
# Load current registry
|
|
208
|
+
registry = self.load_registry()
|
|
209
|
+
|
|
210
|
+
# Generate ID if not provided
|
|
211
|
+
if account_id is None:
|
|
212
|
+
account_id = self.generate_account_id(backend_type, name)
|
|
213
|
+
|
|
214
|
+
# Check for duplicates
|
|
215
|
+
if any(acc.id == account_id for acc in registry.accounts):
|
|
216
|
+
raise ValueError(f"Account ID '{account_id}' already exists")
|
|
217
|
+
|
|
218
|
+
# Create profile directory
|
|
219
|
+
profile_dir = self.get_profile_dir(account_id)
|
|
220
|
+
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
# Create account object
|
|
223
|
+
account = Account(
|
|
224
|
+
id=account_id,
|
|
225
|
+
name=name,
|
|
226
|
+
backend_type=backend_type,
|
|
227
|
+
created_at=datetime.now().isoformat(),
|
|
228
|
+
last_used=None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Add to registry and save
|
|
232
|
+
registry.accounts.append(account)
|
|
233
|
+
registry.last_active_account = account_id
|
|
234
|
+
self.save_registry(registry)
|
|
235
|
+
|
|
236
|
+
return account
|
|
237
|
+
|
|
238
|
+
def delete_account(self, account_id: str) -> bool:
|
|
239
|
+
"""
|
|
240
|
+
Delete an account profile and all its data.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
account_id: Account ID to delete
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if deleted, False if account not found
|
|
247
|
+
|
|
248
|
+
Warning:
|
|
249
|
+
This permanently deletes credentials, cache, and merchant data!
|
|
250
|
+
"""
|
|
251
|
+
registry = self.load_registry()
|
|
252
|
+
|
|
253
|
+
# Find account
|
|
254
|
+
account = next((acc for acc in registry.accounts if acc.id == account_id), None)
|
|
255
|
+
if account is None:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
# Remove from registry
|
|
259
|
+
registry.accounts = [acc for acc in registry.accounts if acc.id != account_id]
|
|
260
|
+
|
|
261
|
+
# Update last_active if we deleted it
|
|
262
|
+
if registry.last_active_account == account_id:
|
|
263
|
+
# Set to first remaining account or None
|
|
264
|
+
registry.last_active_account = registry.accounts[0].id if registry.accounts else None
|
|
265
|
+
|
|
266
|
+
self.save_registry(registry)
|
|
267
|
+
|
|
268
|
+
# Delete profile directory and all contents
|
|
269
|
+
profile_dir = self.get_profile_dir(account_id)
|
|
270
|
+
if profile_dir.exists():
|
|
271
|
+
import shutil
|
|
272
|
+
|
|
273
|
+
shutil.rmtree(profile_dir)
|
|
274
|
+
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def get_account(self, account_id: str) -> Optional[Account]:
|
|
278
|
+
"""
|
|
279
|
+
Get account by ID.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
account_id: Account ID to retrieve
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Account object or None if not found
|
|
286
|
+
"""
|
|
287
|
+
registry = self.load_registry()
|
|
288
|
+
return next((acc for acc in registry.accounts if acc.id == account_id), None)
|
|
289
|
+
|
|
290
|
+
def list_accounts(self) -> List[Account]:
|
|
291
|
+
"""
|
|
292
|
+
List all configured accounts.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of Account objects sorted by last_used (most recent first)
|
|
296
|
+
"""
|
|
297
|
+
registry = self.load_registry()
|
|
298
|
+
|
|
299
|
+
# Sort by last_used (None values go to end)
|
|
300
|
+
def sort_key(acc: Account):
|
|
301
|
+
if acc.last_used is None:
|
|
302
|
+
return "" # Empty string sorts before ISO timestamps
|
|
303
|
+
return acc.last_used
|
|
304
|
+
|
|
305
|
+
return sorted(registry.accounts, key=sort_key, reverse=True)
|
|
306
|
+
|
|
307
|
+
def update_last_used(self, account_id: str) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Update last_used timestamp for an account.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
account_id: Account ID to update
|
|
313
|
+
"""
|
|
314
|
+
registry = self.load_registry()
|
|
315
|
+
|
|
316
|
+
# Find and update account
|
|
317
|
+
for account in registry.accounts:
|
|
318
|
+
if account.id == account_id:
|
|
319
|
+
account.last_used = datetime.now().isoformat()
|
|
320
|
+
break
|
|
321
|
+
|
|
322
|
+
# Update last active
|
|
323
|
+
registry.last_active_account = account_id
|
|
324
|
+
|
|
325
|
+
self.save_registry(registry)
|
|
326
|
+
|
|
327
|
+
def get_profile_dir(self, account_id: str) -> Path:
|
|
328
|
+
"""
|
|
329
|
+
Get profile directory path for an account.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
account_id: Account ID
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Path to profile directory (may not exist yet)
|
|
336
|
+
"""
|
|
337
|
+
return self.profiles_dir / account_id
|
|
338
|
+
|
|
339
|
+
def get_last_active_account(self) -> Optional[Account]:
|
|
340
|
+
"""
|
|
341
|
+
Get the last active account.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Account object or None if no accounts configured
|
|
345
|
+
"""
|
|
346
|
+
registry = self.load_registry()
|
|
347
|
+
|
|
348
|
+
if registry.last_active_account:
|
|
349
|
+
return self.get_account(registry.last_active_account)
|
|
350
|
+
|
|
351
|
+
# Fall back to first account if no last_active set
|
|
352
|
+
if registry.accounts:
|
|
353
|
+
return registry.accounts[0]
|
|
354
|
+
|
|
355
|
+
return None
|