fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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 (83) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/cash_flow.py +6 -5
  3. fin_infra/analytics/portfolio.py +13 -20
  4. fin_infra/analytics/rebalancing.py +2 -4
  5. fin_infra/analytics/savings.py +1 -1
  6. fin_infra/analytics/spending.py +15 -11
  7. fin_infra/banking/__init__.py +8 -5
  8. fin_infra/banking/history.py +3 -3
  9. fin_infra/banking/utils.py +93 -88
  10. fin_infra/brokerage/__init__.py +5 -3
  11. fin_infra/budgets/tracker.py +2 -3
  12. fin_infra/cashflows/__init__.py +6 -8
  13. fin_infra/categorization/__init__.py +1 -1
  14. fin_infra/categorization/add.py +15 -16
  15. fin_infra/categorization/ease.py +3 -4
  16. fin_infra/categorization/engine.py +4 -4
  17. fin_infra/categorization/llm_layer.py +5 -6
  18. fin_infra/categorization/models.py +1 -1
  19. fin_infra/chat/__init__.py +7 -16
  20. fin_infra/chat/planning.py +57 -0
  21. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  22. fin_infra/compliance/__init__.py +3 -3
  23. fin_infra/credit/add.py +3 -2
  24. fin_infra/credit/experian/auth.py +3 -2
  25. fin_infra/credit/experian/client.py +2 -2
  26. fin_infra/credit/experian/provider.py +16 -16
  27. fin_infra/crypto/__init__.py +1 -1
  28. fin_infra/crypto/insights.py +1 -3
  29. fin_infra/documents/add.py +5 -5
  30. fin_infra/documents/ease.py +4 -3
  31. fin_infra/documents/models.py +3 -3
  32. fin_infra/documents/ocr.py +1 -1
  33. fin_infra/documents/storage.py +2 -1
  34. fin_infra/exceptions.py +1 -1
  35. fin_infra/goals/add.py +2 -2
  36. fin_infra/goals/management.py +6 -6
  37. fin_infra/goals/milestones.py +2 -2
  38. fin_infra/insights/__init__.py +7 -8
  39. fin_infra/investments/__init__.py +13 -8
  40. fin_infra/investments/add.py +39 -59
  41. fin_infra/investments/ease.py +16 -13
  42. fin_infra/investments/models.py +130 -64
  43. fin_infra/investments/providers/base.py +3 -8
  44. fin_infra/investments/providers/plaid.py +23 -34
  45. fin_infra/investments/providers/snaptrade.py +22 -40
  46. fin_infra/markets/__init__.py +11 -8
  47. fin_infra/models/accounts.py +2 -1
  48. fin_infra/models/transactions.py +3 -2
  49. fin_infra/net_worth/add.py +8 -5
  50. fin_infra/net_worth/aggregator.py +5 -4
  51. fin_infra/net_worth/calculator.py +8 -6
  52. fin_infra/net_worth/ease.py +36 -15
  53. fin_infra/net_worth/insights.py +4 -4
  54. fin_infra/net_worth/models.py +237 -116
  55. fin_infra/normalization/__init__.py +15 -13
  56. fin_infra/normalization/providers/exchangerate.py +3 -3
  57. fin_infra/obs/classifier.py +2 -2
  58. fin_infra/providers/banking/plaid_client.py +20 -19
  59. fin_infra/providers/banking/teller_client.py +13 -7
  60. fin_infra/providers/base.py +105 -13
  61. fin_infra/providers/brokerage/alpaca.py +7 -7
  62. fin_infra/providers/credit/experian.py +5 -0
  63. fin_infra/providers/market/ccxt_crypto.py +8 -3
  64. fin_infra/providers/tax/mock.py +3 -3
  65. fin_infra/recurring/add.py +20 -9
  66. fin_infra/recurring/detector.py +1 -1
  67. fin_infra/recurring/detectors_llm.py +10 -9
  68. fin_infra/recurring/ease.py +1 -1
  69. fin_infra/recurring/insights.py +9 -8
  70. fin_infra/recurring/models.py +3 -3
  71. fin_infra/recurring/normalizer.py +3 -2
  72. fin_infra/recurring/normalizers.py +9 -8
  73. fin_infra/scaffold/__init__.py +1 -1
  74. fin_infra/security/encryption.py +2 -2
  75. fin_infra/security/pii_patterns.py +1 -1
  76. fin_infra/security/token_store.py +3 -1
  77. fin_infra/tax/__init__.py +1 -1
  78. fin_infra/utils/http.py +3 -2
  79. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
  80. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
  81. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
  82. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
  83. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
@@ -8,23 +8,23 @@ Apps still manage user-to-token mappings, but these utilities simplify common op
8
8
  from __future__ import annotations
9
9
 
10
10
  import re
11
- from datetime import datetime, timezone, timedelta
11
+ from datetime import datetime, timezone
12
12
  from typing import Any, Dict, Optional, Literal
13
13
  from pydantic import BaseModel, ConfigDict, Field
14
- from pydantic.json_schema import JsonSchemaValue
15
- from pydantic_core import core_schema
16
14
 
17
15
  from ..providers.base import BankingProvider
18
16
 
19
17
 
20
18
  class BankingConnectionInfo(BaseModel):
21
19
  """Information about a banking provider connection."""
22
-
20
+
23
21
  model_config = ConfigDict()
24
-
22
+
25
23
  provider: Literal["plaid", "teller", "mx"]
26
24
  connected: bool
27
- access_token: Optional[str] = Field(None, description="Token (only for internal use, never expose)")
25
+ access_token: Optional[str] = Field(
26
+ None, description="Token (only for internal use, never expose)"
27
+ )
28
28
  item_id: Optional[str] = None
29
29
  enrollment_id: Optional[str] = None
30
30
  connected_at: Optional[datetime] = None
@@ -35,12 +35,12 @@ class BankingConnectionInfo(BaseModel):
35
35
 
36
36
  class BankingConnectionStatus(BaseModel):
37
37
  """Status of all banking connections for a user."""
38
-
38
+
39
39
  plaid: Optional[BankingConnectionInfo] = None
40
40
  teller: Optional[BankingConnectionInfo] = None
41
41
  mx: Optional[BankingConnectionInfo] = None
42
42
  has_any_connection: bool = False
43
-
43
+
44
44
  @property
45
45
  def connected_providers(self) -> list[str]:
46
46
  """List of connected provider names."""
@@ -52,13 +52,13 @@ class BankingConnectionStatus(BaseModel):
52
52
  if self.mx and self.mx.connected:
53
53
  providers.append("mx")
54
54
  return providers
55
-
55
+
56
56
  @property
57
57
  def primary_provider(self) -> Optional[str]:
58
58
  """Primary provider (first connected, or most recently synced)."""
59
59
  if not self.has_any_connection:
60
60
  return None
61
-
61
+
62
62
  # Preference order: plaid > teller > mx
63
63
  if self.plaid and self.plaid.connected:
64
64
  return "plaid"
@@ -72,17 +72,17 @@ class BankingConnectionStatus(BaseModel):
72
72
  def validate_plaid_token(access_token: str) -> bool:
73
73
  """
74
74
  Validate Plaid access token format.
75
-
75
+
76
76
  Args:
77
77
  access_token: Plaid access token to validate
78
-
78
+
79
79
  Returns:
80
80
  True if token format is valid
81
-
81
+
82
82
  Note:
83
83
  This only validates format, not that the token is active/unexpired.
84
84
  Use provider's API to verify token health.
85
-
85
+
86
86
  Example:
87
87
  >>> validate_plaid_token("access-sandbox-abc123")
88
88
  True
@@ -91,26 +91,26 @@ def validate_plaid_token(access_token: str) -> bool:
91
91
  """
92
92
  if not access_token:
93
93
  return False
94
-
94
+
95
95
  # Plaid tokens typically start with "access-{environment}-"
96
- pattern = r'^access-(sandbox|development|production)-[a-zA-Z0-9-_]+$'
96
+ pattern = r"^access-(sandbox|development|production)-[a-zA-Z0-9-_]+$"
97
97
  return bool(re.match(pattern, access_token))
98
98
 
99
99
 
100
100
  def validate_teller_token(access_token: str) -> bool:
101
101
  """
102
102
  Validate Teller access token format.
103
-
103
+
104
104
  Args:
105
105
  access_token: Teller access token to validate
106
-
106
+
107
107
  Returns:
108
108
  True if token format is valid
109
-
109
+
110
110
  Note:
111
111
  This only validates format, not that the token is active/unexpired.
112
112
  Use provider's API to verify token health.
113
-
113
+
114
114
  Example:
115
115
  >>> validate_teller_token("test_token_abc123")
116
116
  True
@@ -119,46 +119,46 @@ def validate_teller_token(access_token: str) -> bool:
119
119
  """
120
120
  if not access_token:
121
121
  return False
122
-
122
+
123
123
  # Teller tokens are typically alphanumeric with underscores
124
124
  # Sandbox tokens often start with "test_"
125
- pattern = r'^[a-zA-Z0-9_-]{10,}$'
125
+ pattern = r"^[a-zA-Z0-9_-]{10,}$"
126
126
  return bool(re.match(pattern, access_token))
127
127
 
128
128
 
129
129
  def validate_mx_token(access_token: str) -> bool:
130
130
  """
131
131
  Validate MX access token format.
132
-
132
+
133
133
  Args:
134
134
  access_token: MX access token to validate
135
-
135
+
136
136
  Returns:
137
137
  True if token format is valid
138
-
138
+
139
139
  Example:
140
140
  >>> validate_mx_token("USR-abc123")
141
141
  True
142
142
  """
143
143
  if not access_token:
144
144
  return False
145
-
145
+
146
146
  # MX tokens typically have a prefix like "USR-"
147
- pattern = r'^[A-Z]+-[a-zA-Z0-9-_]+$'
147
+ pattern = r"^[A-Z]+-[a-zA-Z0-9-_]+$"
148
148
  return bool(re.match(pattern, access_token))
149
149
 
150
150
 
151
151
  def validate_provider_token(provider: str, access_token: str) -> bool:
152
152
  """
153
153
  Validate token format for any provider.
154
-
154
+
155
155
  Args:
156
156
  provider: Provider name ("plaid", "teller", "mx")
157
157
  access_token: Token to validate
158
-
158
+
159
159
  Returns:
160
160
  True if token format is valid for the provider
161
-
161
+
162
162
  Example:
163
163
  >>> validate_provider_token("plaid", "access-sandbox-abc")
164
164
  True
@@ -170,29 +170,29 @@ def validate_provider_token(provider: str, access_token: str) -> bool:
170
170
  "teller": validate_teller_token,
171
171
  "mx": validate_mx_token,
172
172
  }
173
-
173
+
174
174
  validator = validators.get(provider.lower())
175
175
  if not validator:
176
176
  # Unknown provider - do basic validation
177
177
  return bool(access_token and len(access_token) > 10)
178
-
178
+
179
179
  return validator(access_token)
180
180
 
181
181
 
182
182
  def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnectionStatus:
183
183
  """
184
184
  Parse banking_providers JSON field into structured status.
185
-
185
+
186
186
  Args:
187
187
  banking_providers: Dictionary from User.banking_providers field
188
188
  Structure: {
189
189
  "plaid": {"access_token": "...", "item_id": "...", "connected_at": "..."},
190
190
  "teller": {"access_token": "...", "enrollment_id": "..."}
191
191
  }
192
-
192
+
193
193
  Returns:
194
194
  Structured status with connection info for all providers
195
-
195
+
196
196
  Example:
197
197
  >>> status = parse_banking_providers(user.banking_providers)
198
198
  >>> if status.has_any_connection:
@@ -201,10 +201,10 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
201
201
  ... print(f"Connected: {provider}")
202
202
  """
203
203
  status = BankingConnectionStatus()
204
-
204
+
205
205
  if not banking_providers:
206
206
  return status
207
-
207
+
208
208
  # Parse Plaid
209
209
  if "plaid" in banking_providers:
210
210
  plaid_data = banking_providers["plaid"]
@@ -218,7 +218,7 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
218
218
  is_healthy=plaid_data.get("is_healthy", True),
219
219
  error_message=plaid_data.get("error_message"),
220
220
  )
221
-
221
+
222
222
  # Parse Teller
223
223
  if "teller" in banking_providers:
224
224
  teller_data = banking_providers["teller"]
@@ -232,7 +232,7 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
232
232
  is_healthy=teller_data.get("is_healthy", True),
233
233
  error_message=teller_data.get("error_message"),
234
234
  )
235
-
235
+
236
236
  # Parse MX
237
237
  if "mx" in banking_providers:
238
238
  mx_data = banking_providers["mx"]
@@ -245,42 +245,45 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
245
245
  is_healthy=mx_data.get("is_healthy", True),
246
246
  error_message=mx_data.get("error_message"),
247
247
  )
248
-
249
- status.has_any_connection = any([
250
- status.plaid and status.plaid.connected,
251
- status.teller and status.teller.connected,
252
- status.mx and status.mx.connected,
253
- ])
254
-
248
+
249
+ status.has_any_connection = any(
250
+ [
251
+ status.plaid and status.plaid.connected,
252
+ status.teller and status.teller.connected,
253
+ status.mx and status.mx.connected,
254
+ ]
255
+ )
256
+
255
257
  return status
256
258
 
257
259
 
258
260
  def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any]:
259
261
  """
260
262
  Sanitize connection status for API responses (removes access tokens).
261
-
263
+
262
264
  Args:
263
265
  status: Connection status with tokens
264
-
266
+
265
267
  Returns:
266
268
  Dictionary safe for API responses (no tokens)
267
-
269
+
268
270
  Example:
269
271
  >>> status = parse_banking_providers(user.banking_providers)
270
272
  >>> safe_data = sanitize_connection_status(status)
271
273
  >>> return {"connections": safe_data} # Safe to return to client
272
274
  """
273
- result = {
275
+ result: dict[str, Any] = {
274
276
  "has_any_connection": status.has_any_connection,
275
277
  "connected_providers": status.connected_providers,
276
278
  "primary_provider": status.primary_provider,
277
279
  "providers": {},
278
280
  }
279
-
281
+
280
282
  for provider_name in ["plaid", "teller", "mx"]:
281
283
  info = getattr(status, provider_name)
282
284
  if info:
283
- result["providers"][provider_name] = {
285
+ providers_dict: dict[str, Any] = result["providers"]
286
+ providers_dict[provider_name] = {
284
287
  "connected": info.connected,
285
288
  "item_id": info.item_id,
286
289
  "enrollment_id": info.enrollment_id,
@@ -290,7 +293,7 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
290
293
  "error_message": info.error_message,
291
294
  # NO access_token - this is sanitized
292
295
  }
293
-
296
+
294
297
  return result
295
298
 
296
299
 
@@ -301,15 +304,15 @@ def mark_connection_unhealthy(
301
304
  ) -> Dict[str, Any]:
302
305
  """
303
306
  Mark a provider connection as unhealthy (for error handling).
304
-
307
+
305
308
  Args:
306
309
  banking_providers: Current banking_providers dict
307
310
  provider: Provider name ("plaid", "teller", "mx")
308
311
  error_message: Error description
309
-
312
+
310
313
  Returns:
311
314
  Updated banking_providers dict
312
-
315
+
313
316
  Example:
314
317
  >>> try:
315
318
  ... accounts = await banking.get_accounts(access_token)
@@ -323,11 +326,11 @@ def mark_connection_unhealthy(
323
326
  """
324
327
  if provider not in banking_providers:
325
328
  return banking_providers
326
-
329
+
327
330
  banking_providers[provider]["is_healthy"] = False
328
331
  banking_providers[provider]["error_message"] = error_message
329
332
  banking_providers[provider]["error_at"] = datetime.now(timezone.utc).isoformat()
330
-
333
+
331
334
  return banking_providers
332
335
 
333
336
 
@@ -337,14 +340,14 @@ def mark_connection_healthy(
337
340
  ) -> Dict[str, Any]:
338
341
  """
339
342
  Mark a provider connection as healthy (after successful sync).
340
-
343
+
341
344
  Args:
342
345
  banking_providers: Current banking_providers dict
343
346
  provider: Provider name
344
-
347
+
345
348
  Returns:
346
349
  Updated banking_providers dict
347
-
350
+
348
351
  Example:
349
352
  >>> accounts = await banking.get_accounts(access_token)
350
353
  >>> user.banking_providers = mark_connection_healthy(
@@ -356,26 +359,28 @@ def mark_connection_healthy(
356
359
  """
357
360
  if provider not in banking_providers:
358
361
  return banking_providers
359
-
362
+
360
363
  banking_providers[provider]["is_healthy"] = True
361
364
  banking_providers[provider]["error_message"] = None
362
365
  banking_providers[provider]["last_synced_at"] = datetime.now(timezone.utc).isoformat()
363
-
366
+
364
367
  return banking_providers
365
368
 
366
369
 
367
- def get_primary_access_token(banking_providers: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
370
+ def get_primary_access_token(
371
+ banking_providers: Dict[str, Any],
372
+ ) -> tuple[Optional[str], Optional[str]]:
368
373
  """
369
374
  Get the primary access token and provider name.
370
-
375
+
371
376
  Returns the first healthy, connected provider in priority order: plaid > teller > mx.
372
-
377
+
373
378
  Args:
374
379
  banking_providers: Dictionary from User.banking_providers
375
-
380
+
376
381
  Returns:
377
382
  Tuple of (access_token, provider_name) or (None, None)
378
-
383
+
379
384
  Example:
380
385
  >>> access_token, provider = get_primary_access_token(user.banking_providers)
381
386
  >>> if access_token:
@@ -383,13 +388,13 @@ def get_primary_access_token(banking_providers: Dict[str, Any]) -> tuple[Optiona
383
388
  ... accounts = await banking.get_accounts(access_token)
384
389
  """
385
390
  status = parse_banking_providers(banking_providers)
386
-
391
+
387
392
  # Priority order: plaid > teller > mx
388
393
  for provider_name in ["plaid", "teller", "mx"]:
389
394
  info = getattr(status, provider_name)
390
395
  if info and info.connected and info.is_healthy and info.access_token:
391
396
  return info.access_token, provider_name
392
-
397
+
393
398
  return None, None
394
399
 
395
400
 
@@ -399,14 +404,14 @@ async def test_connection_health(
399
404
  ) -> tuple[bool, Optional[str]]:
400
405
  """
401
406
  Test if a banking connection is healthy by making a lightweight API call.
402
-
407
+
403
408
  Args:
404
409
  provider: Banking provider instance (from easy_banking())
405
410
  access_token: Access token to test
406
-
411
+
407
412
  Returns:
408
413
  Tuple of (is_healthy, error_message)
409
-
414
+
410
415
  Example:
411
416
  >>> banking = easy_banking(provider="plaid")
412
417
  >>> is_healthy, error = await test_connection_health(banking, access_token)
@@ -415,14 +420,14 @@ async def test_connection_health(
415
420
  """
416
421
  try:
417
422
  # Try to fetch accounts (lightweight call)
418
- accounts = provider.accounts(access_token)
419
-
423
+ provider.accounts(access_token)
424
+
420
425
  # If we got here, connection is healthy
421
426
  return True, None
422
-
427
+
423
428
  except Exception as e:
424
429
  error_msg = str(e)
425
-
430
+
426
431
  # Check for common error patterns
427
432
  if "unauthorized" in error_msg.lower() or "invalid" in error_msg.lower():
428
433
  return False, "Token invalid or expired"
@@ -435,14 +440,14 @@ async def test_connection_health(
435
440
  def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bool:
436
441
  """
437
442
  Check if a provider token should be refreshed.
438
-
443
+
439
444
  Args:
440
445
  banking_providers: Current banking_providers dict
441
446
  provider: Provider name
442
-
447
+
443
448
  Returns:
444
449
  True if token should be refreshed
445
-
450
+
446
451
  Example:
447
452
  >>> if should_refresh_token(user.banking_providers, "plaid"):
448
453
  ... # Trigger token refresh flow
@@ -450,13 +455,13 @@ def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bo
450
455
  """
451
456
  if provider not in banking_providers:
452
457
  return False
453
-
458
+
454
459
  provider_data = banking_providers[provider]
455
-
460
+
456
461
  # Check if marked unhealthy
457
462
  if not provider_data.get("is_healthy", True):
458
463
  return True
459
-
464
+
460
465
  # Check last sync time
461
466
  last_synced_str = provider_data.get("last_synced_at")
462
467
  if last_synced_str:
@@ -466,7 +471,7 @@ def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bo
466
471
  days_since_sync = (datetime.now(timezone.utc) - last_synced).days
467
472
  if days_since_sync > 30:
468
473
  return True
469
-
474
+
470
475
  return False
471
476
 
472
477
 
@@ -474,15 +479,15 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
474
479
  """Parse datetime from various formats."""
475
480
  if not value:
476
481
  return None
477
-
482
+
478
483
  if isinstance(value, datetime):
479
484
  return value
480
-
485
+
481
486
  if isinstance(value, str):
482
487
  try:
483
488
  # Try ISO format
484
- return datetime.fromisoformat(value.replace('Z', '+00:00'))
489
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
485
490
  except (ValueError, AttributeError):
486
491
  pass
487
-
492
+
488
493
  return None
@@ -123,7 +123,7 @@ def easy_brokerage(
123
123
  )
124
124
 
125
125
  else:
126
- raise ValueError(f"Unknown brokerage provider: {provider_name}. " f"Supported: alpaca")
126
+ raise ValueError(f"Unknown brokerage provider: {provider_name}. Supported: alpaca")
127
127
 
128
128
 
129
129
  def add_brokerage(
@@ -212,7 +212,9 @@ def add_brokerage(
212
212
 
213
213
  # Initialize provider if string or None
214
214
  if isinstance(provider, str):
215
- brokerage_provider = easy_brokerage(provider=provider, mode=mode, **config)
215
+ # Cast provider string to Literal type for type checker
216
+ provider_literal: Literal["alpaca"] | None = provider if provider == "alpaca" else None # type: ignore[assignment]
217
+ brokerage_provider = easy_brokerage(provider=provider_literal, mode=mode, **config)
216
218
  elif provider is None:
217
219
  brokerage_provider = easy_brokerage(mode=mode, **config)
218
220
  else:
@@ -241,7 +243,7 @@ def add_brokerage(
241
243
  Returns list of positions with symbol, quantity, P/L, etc.
242
244
  """
243
245
  try:
244
- positions = brokerage_provider.positions()
246
+ positions = list(brokerage_provider.positions()) # Convert Iterable to list for len()
245
247
  return {"positions": positions, "count": len(positions)}
246
248
  except Exception as e:
247
249
  raise HTTPException(status_code=500, detail=f"Error fetching positions: {str(e)}")
@@ -155,7 +155,7 @@ class BudgetTracker:
155
155
  BudgetType(type)
156
156
  except ValueError:
157
157
  raise ValueError(
158
- f"Invalid budget type: {type}. " f"Valid types: {[t.value for t in BudgetType]}"
158
+ f"Invalid budget type: {type}. Valid types: {[t.value for t in BudgetType]}"
159
159
  )
160
160
 
161
161
  # Validate budget period
@@ -163,8 +163,7 @@ class BudgetTracker:
163
163
  BudgetPeriod(period)
164
164
  except ValueError:
165
165
  raise ValueError(
166
- f"Invalid budget period: {period}. "
167
- f"Valid periods: {[p.value for p in BudgetPeriod]}"
166
+ f"Invalid budget period: {period}. Valid periods: {[p.value for p in BudgetPeriod]}"
168
167
  )
169
168
 
170
169
  # Validate categories
@@ -22,10 +22,13 @@ Example usage:
22
22
  rate = irr(cashflows)
23
23
  """
24
24
 
25
- from typing import Iterable
25
+ from typing import TYPE_CHECKING
26
26
 
27
27
  import numpy_financial as npf
28
28
 
29
+ if TYPE_CHECKING:
30
+ from fastapi import FastAPI
31
+
29
32
  from .core import npv, irr
30
33
 
31
34
  __all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
@@ -110,7 +113,7 @@ def pv(rate: float, nper: int, pmt: float, fv: float = 0, when: str = "end") ->
110
113
 
111
114
 
112
115
  def add_cashflows(
113
- app: "FastAPI", # type: ignore
116
+ app: "FastAPI",
114
117
  *,
115
118
  prefix: str = "/cashflows",
116
119
  ) -> None:
@@ -169,11 +172,6 @@ def add_cashflows(
169
172
  - Integrated with svc-infra observability
170
173
  - Scoped docs at {prefix}/docs
171
174
  """
172
- from typing import TYPE_CHECKING
173
-
174
- if TYPE_CHECKING:
175
- from fastapi import FastAPI
176
-
177
175
  from pydantic import BaseModel, Field
178
176
 
179
177
  # Import svc-infra public router (no auth - utility calculations)
@@ -254,4 +252,4 @@ def add_cashflows(
254
252
  # Mount router
255
253
  app.include_router(router, include_in_schema=True)
256
254
 
257
- print(f"✅ Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
255
+ print("✅ Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
@@ -44,7 +44,7 @@ from .taxonomy import Category, CategoryGroup, get_all_categories, get_category_
44
44
  try:
45
45
  from .llm_layer import LLMCategorizer
46
46
  except ImportError:
47
- LLMCategorizer = None
47
+ LLMCategorizer = None # type: ignore[assignment,misc]
48
48
 
49
49
  __all__ = [
50
50
  # Easy setup
@@ -96,7 +96,8 @@ def add_categorization(
96
96
  start_time = time.perf_counter()
97
97
 
98
98
  try:
99
- prediction = engine.categorize(
99
+ # Await the async categorize method
100
+ prediction = await engine.categorize(
100
101
  merchant_name=request.merchant_name,
101
102
  user_id=request.user_id,
102
103
  include_alternatives=request.include_alternatives,
@@ -135,21 +136,19 @@ def add_categorization(
135
136
  categories = get_all_categories()
136
137
 
137
138
  # Return category metadata
138
- return [
139
- {
140
- "name": cat.value,
141
- "group": get_category_metadata(cat).group.value
142
- if get_category_metadata(cat)
143
- else None,
144
- "display_name": get_category_metadata(cat).display_name
145
- if get_category_metadata(cat)
146
- else cat.value,
147
- "description": get_category_metadata(cat).description
148
- if get_category_metadata(cat)
149
- else None,
150
- }
151
- for cat in categories
152
- ]
139
+ result = []
140
+ for cat in categories:
141
+ meta = get_category_metadata(cat)
142
+ result.append(
143
+ {
144
+ "name": cat.value,
145
+ "group": meta.group.value if meta else None,
146
+ "display_name": meta.display_name if meta else cat.value,
147
+ "description": meta.description if meta else None,
148
+ }
149
+ )
150
+
151
+ return result
153
152
 
154
153
  @router.get("/stats", response_model=CategoryStats)
155
154
  async def get_stats():
@@ -13,7 +13,7 @@ from .engine import CategorizationEngine
13
13
  try:
14
14
  from .llm_layer import LLMCategorizer
15
15
  except ImportError:
16
- LLMCategorizer = None
16
+ LLMCategorizer = None # type: ignore[assignment,misc]
17
17
 
18
18
 
19
19
  def easy_categorization(
@@ -113,7 +113,7 @@ def easy_categorization(
113
113
  if enable_llm:
114
114
  if LLMCategorizer is None:
115
115
  raise ImportError(
116
- "LLM support requires ai-infra package. " "Install with: pip install ai-infra"
116
+ "LLM support requires ai-infra package. Install with: pip install ai-infra"
117
117
  )
118
118
 
119
119
  # Map provider names to ai-infra provider format
@@ -125,8 +125,7 @@ def easy_categorization(
125
125
  ai_infra_provider = provider_map.get(llm_provider)
126
126
  if not ai_infra_provider:
127
127
  raise ValueError(
128
- f"Unsupported LLM provider: {llm_provider}. "
129
- f"Use 'google', 'openai', or 'anthropic'."
128
+ f"Unsupported LLM provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'."
130
129
  )
131
130
 
132
131
  # Default models per provider