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.
Files changed (52) hide show
  1. moneyflow/__init__.py +28 -0
  2. moneyflow/__main__.py +6 -0
  3. moneyflow/account_manager.py +355 -0
  4. moneyflow/app.py +2270 -0
  5. moneyflow/app_controller.py +1673 -0
  6. moneyflow/backend_config.py +97 -0
  7. moneyflow/backends/__init__.py +107 -0
  8. moneyflow/backends/amazon.py +495 -0
  9. moneyflow/backends/base.py +256 -0
  10. moneyflow/backends/demo.py +192 -0
  11. moneyflow/backends/monarch.py +176 -0
  12. moneyflow/backends/ynab.py +205 -0
  13. moneyflow/cache_manager.py +218 -0
  14. moneyflow/categories.py +654 -0
  15. moneyflow/cli.py +464 -0
  16. moneyflow/commit_orchestrator.py +244 -0
  17. moneyflow/credentials.py +327 -0
  18. moneyflow/data_manager.py +1162 -0
  19. moneyflow/demo_data_generator.py +789 -0
  20. moneyflow/duplicate_detector.py +222 -0
  21. moneyflow/formatters.py +680 -0
  22. moneyflow/importers/__init__.py +0 -0
  23. moneyflow/importers/amazon_orders_csv.py +215 -0
  24. moneyflow/keybindings.py +110 -0
  25. moneyflow/logging_config.py +70 -0
  26. moneyflow/migration.py +291 -0
  27. moneyflow/modal_helper.py +242 -0
  28. moneyflow/monarchmoney.py +2986 -0
  29. moneyflow/notification_helper.py +240 -0
  30. moneyflow/retry_logic.py +94 -0
  31. moneyflow/screens/__init__.py +1 -0
  32. moneyflow/screens/account_name_input_screen.py +180 -0
  33. moneyflow/screens/account_selector_screen.py +333 -0
  34. moneyflow/screens/credential_screens.py +834 -0
  35. moneyflow/screens/duplicates_screen.py +439 -0
  36. moneyflow/screens/edit_screens.py +619 -0
  37. moneyflow/screens/review_screen.py +146 -0
  38. moneyflow/screens/search_screen.py +88 -0
  39. moneyflow/screens/transaction_detail_screen.py +139 -0
  40. moneyflow/state.py +1270 -0
  41. moneyflow/styles/moneyflow.tcss +113 -0
  42. moneyflow/textual_view.py +89 -0
  43. moneyflow/time_navigator.py +299 -0
  44. moneyflow/view_interface.py +80 -0
  45. moneyflow/widgets/__init__.py +5 -0
  46. moneyflow/widgets/help_screen.py +71 -0
  47. moneyflow/ynab_client.py +519 -0
  48. moneyflow-0.7.0.dist-info/METADATA +281 -0
  49. moneyflow-0.7.0.dist-info/RECORD +52 -0
  50. moneyflow-0.7.0.dist-info/WHEEL +4 -0
  51. moneyflow-0.7.0.dist-info/entry_points.txt +3 -0
  52. 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,6 @@
1
+ """Entry point for python -m moneyflow"""
2
+
3
+ from .cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -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