fin-infra 0.1.67__py3-none-any.whl → 0.1.68__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 (50) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/portfolio.py +12 -18
  3. fin_infra/analytics/rebalancing.py +2 -4
  4. fin_infra/analytics/savings.py +1 -1
  5. fin_infra/analytics/spending.py +3 -1
  6. fin_infra/banking/history.py +3 -3
  7. fin_infra/banking/utils.py +88 -82
  8. fin_infra/brokerage/__init__.py +1 -1
  9. fin_infra/budgets/tracker.py +2 -3
  10. fin_infra/categorization/ease.py +2 -3
  11. fin_infra/categorization/llm_layer.py +2 -2
  12. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  13. fin_infra/credit/experian/provider.py +14 -14
  14. fin_infra/crypto/__init__.py +1 -1
  15. fin_infra/documents/add.py +4 -4
  16. fin_infra/documents/ease.py +4 -3
  17. fin_infra/documents/models.py +3 -3
  18. fin_infra/documents/ocr.py +1 -1
  19. fin_infra/documents/storage.py +2 -1
  20. fin_infra/exceptions.py +1 -1
  21. fin_infra/goals/management.py +3 -3
  22. fin_infra/insights/__init__.py +0 -1
  23. fin_infra/investments/__init__.py +2 -4
  24. fin_infra/investments/add.py +37 -56
  25. fin_infra/investments/ease.py +7 -8
  26. fin_infra/investments/models.py +29 -17
  27. fin_infra/investments/providers/base.py +3 -8
  28. fin_infra/investments/providers/plaid.py +19 -29
  29. fin_infra/investments/providers/snaptrade.py +18 -36
  30. fin_infra/markets/__init__.py +4 -2
  31. fin_infra/models/accounts.py +2 -1
  32. fin_infra/models/transactions.py +2 -1
  33. fin_infra/net_worth/calculator.py +8 -6
  34. fin_infra/net_worth/ease.py +2 -2
  35. fin_infra/net_worth/insights.py +4 -4
  36. fin_infra/normalization/__init__.py +3 -1
  37. fin_infra/providers/banking/plaid_client.py +16 -16
  38. fin_infra/providers/base.py +5 -5
  39. fin_infra/providers/brokerage/alpaca.py +2 -2
  40. fin_infra/providers/market/ccxt_crypto.py +4 -1
  41. fin_infra/recurring/add.py +3 -1
  42. fin_infra/recurring/detector.py +1 -1
  43. fin_infra/recurring/normalizer.py +1 -1
  44. fin_infra/scaffold/__init__.py +1 -1
  45. fin_infra/tax/__init__.py +1 -1
  46. {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
  47. {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
  48. {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
  49. {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
  50. {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
@@ -208,12 +208,10 @@ def add_analytics(
208
208
  user_id: str,
209
209
  accounts: Optional[list[str]] = None,
210
210
  with_holdings: bool = Query(
211
- False,
212
- description="Use real holdings data from investment provider for accurate P/L"
211
+ False, description="Use real holdings data from investment provider for accurate P/L"
213
212
  ),
214
213
  access_token: Optional[str] = Query(
215
- None,
216
- description="Investment provider access token (required if with_holdings=true)"
214
+ None, description="Investment provider access token (required if with_holdings=true)"
217
215
  ),
218
216
  ) -> PortfolioMetrics:
219
217
  """
@@ -249,31 +247,31 @@ def add_analytics(
249
247
  if with_holdings:
250
248
  # Check if investment provider is available on app state
251
249
  investment_provider = getattr(app.state, "investment_provider", None)
252
-
250
+
253
251
  if investment_provider and access_token:
254
252
  try:
255
253
  # Fetch real holdings from investment provider
256
254
  from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
257
-
255
+
258
256
  holdings = await investment_provider.get_holdings(
259
257
  access_token=access_token,
260
258
  account_ids=accounts,
261
259
  )
262
-
260
+
263
261
  # Calculate metrics from real holdings
264
262
  return portfolio_metrics_with_holdings(holdings)
265
-
263
+
266
264
  except Exception as e:
267
265
  # Fall back to balance-only calculation on error
268
266
  # Log error but don't fail the request
269
267
  import logging
268
+
270
269
  logging.warning(f"Failed to fetch holdings, falling back to balance-only: {e}")
271
270
  elif with_holdings and not access_token:
272
271
  raise HTTPException(
273
- status_code=400,
274
- detail="access_token required when with_holdings=true"
272
+ status_code=400, detail="access_token required when with_holdings=true"
275
273
  )
276
-
274
+
277
275
  # Default: Use balance-only calculation (existing behavior)
278
276
  return await provider.portfolio_metrics(
279
277
  user_id,
@@ -567,22 +567,16 @@ def portfolio_metrics_with_holdings(holdings: list) -> PortfolioMetrics:
567
567
  # Import here to avoid circular dependency
568
568
 
569
569
  # Calculate total portfolio value and cost basis
570
- total_value = float(sum(
571
- holding.institution_value
572
- for holding in holdings
573
- ))
570
+ total_value = float(sum(holding.institution_value for holding in holdings))
574
571
 
575
- total_cost_basis = float(sum(
576
- holding.cost_basis if holding.cost_basis is not None else 0
577
- for holding in holdings
578
- ))
572
+ total_cost_basis = float(
573
+ sum(holding.cost_basis if holding.cost_basis is not None else 0 for holding in holdings)
574
+ )
579
575
 
580
576
  # Calculate total return (P/L)
581
577
  total_return_dollars = total_value - total_cost_basis
582
578
  total_return_percent = (
583
- (total_return_dollars / total_cost_basis * 100.0)
584
- if total_cost_basis > 0
585
- else 0.0
579
+ (total_return_dollars / total_cost_basis * 100.0) if total_cost_basis > 0 else 0.0
586
580
  )
587
581
 
588
582
  # Calculate asset allocation from real security types
@@ -684,9 +678,7 @@ def calculate_day_change_with_snapshot(
684
678
  # Calculate day change
685
679
  day_change_dollars = current_total - previous_total
686
680
  day_change_percent = (
687
- (day_change_dollars / previous_total * 100.0)
688
- if previous_total > 0
689
- else 0.0
681
+ (day_change_dollars / previous_total * 100.0) if previous_total > 0 else 0.0
690
682
  )
691
683
 
692
684
  return {
@@ -740,16 +732,18 @@ def _calculate_allocation_from_holdings(
740
732
  # Sum values by asset class
741
733
  allocation_values: dict[str, float] = defaultdict(float)
742
734
  for holding in holdings:
743
- security_type = holding.security.type.value if hasattr(holding.security.type, 'value') else holding.security.type
735
+ security_type = (
736
+ holding.security.type.value
737
+ if hasattr(holding.security.type, "value")
738
+ else holding.security.type
739
+ )
744
740
  asset_class = type_to_class.get(security_type, "Other")
745
741
  allocation_values[asset_class] += float(holding.institution_value)
746
742
 
747
743
  # Convert to list of AssetAllocation objects
748
744
  allocation_list = [
749
745
  AssetAllocation(
750
- asset_class=asset_class,
751
- value=value,
752
- percentage=round((value / total_value) * 100.0, 2)
746
+ asset_class=asset_class, value=value, percentage=round((value / total_value) * 100.0, 2)
753
747
  )
754
748
  for asset_class, value in allocation_values.items()
755
749
  ]
@@ -329,13 +329,11 @@ def _generate_trade_reasoning(
329
329
 
330
330
  if action == "buy":
331
331
  return (
332
- f"Buy {symbol} to increase {asset_class} allocation "
333
- f"by {diff_pct:.1f}% towards target"
332
+ f"Buy {symbol} to increase {asset_class} allocation by {diff_pct:.1f}% towards target"
334
333
  )
335
334
  else:
336
335
  return (
337
- f"Sell {symbol} to decrease {asset_class} allocation "
338
- f"by {diff_pct:.1f}% towards target"
336
+ f"Sell {symbol} to decrease {asset_class} allocation by {diff_pct:.1f}% towards target"
339
337
  )
340
338
 
341
339
 
@@ -77,7 +77,7 @@ async def calculate_savings_rate(
77
77
  period_enum = Period(period)
78
78
  except ValueError:
79
79
  raise ValueError(
80
- f"Invalid period '{period}'. Must be one of: " f"{', '.join([p.value for p in Period])}"
80
+ f"Invalid period '{period}'. Must be one of: {', '.join([p.value for p in Period])}"
81
81
  )
82
82
 
83
83
  try:
@@ -344,7 +344,9 @@ async def _detect_spending_anomalies(
344
344
  average_amount = current_amount * Decimal("0.8")
345
345
 
346
346
  deviation_percent: float = (
347
- float((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0.0
347
+ float((current_amount - average_amount) / average_amount) * 100
348
+ if average_amount > 0
349
+ else 0.0
348
350
  )
349
351
 
350
352
  # Detect anomalies based on deviation
@@ -68,10 +68,10 @@ def _check_in_memory_warning() -> None:
68
68
  global _production_warning_logged
69
69
  if _production_warning_logged:
70
70
  return
71
-
71
+
72
72
  env = os.getenv("ENV", "development").lower()
73
73
  storage_backend = os.getenv("FIN_INFRA_STORAGE_BACKEND", "memory").lower()
74
-
74
+
75
75
  if env in ("production", "staging") and storage_backend == "memory":
76
76
  _logger.warning(
77
77
  "⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
@@ -135,7 +135,7 @@ def record_balance_snapshot(
135
135
  """
136
136
  # Check if in-memory storage is being used in production
137
137
  _check_in_memory_warning()
138
-
138
+
139
139
  snapshot = BalanceSnapshot(
140
140
  account_id=account_id,
141
141
  balance=balance,
@@ -17,12 +17,14 @@ from ..providers.base import BankingProvider
17
17
 
18
18
  class BankingConnectionInfo(BaseModel):
19
19
  """Information about a banking provider connection."""
20
-
20
+
21
21
  model_config = ConfigDict()
22
-
22
+
23
23
  provider: Literal["plaid", "teller", "mx"]
24
24
  connected: bool
25
- 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
+ )
26
28
  item_id: Optional[str] = None
27
29
  enrollment_id: Optional[str] = None
28
30
  connected_at: Optional[datetime] = None
@@ -33,12 +35,12 @@ class BankingConnectionInfo(BaseModel):
33
35
 
34
36
  class BankingConnectionStatus(BaseModel):
35
37
  """Status of all banking connections for a user."""
36
-
38
+
37
39
  plaid: Optional[BankingConnectionInfo] = None
38
40
  teller: Optional[BankingConnectionInfo] = None
39
41
  mx: Optional[BankingConnectionInfo] = None
40
42
  has_any_connection: bool = False
41
-
43
+
42
44
  @property
43
45
  def connected_providers(self) -> list[str]:
44
46
  """List of connected provider names."""
@@ -50,13 +52,13 @@ class BankingConnectionStatus(BaseModel):
50
52
  if self.mx and self.mx.connected:
51
53
  providers.append("mx")
52
54
  return providers
53
-
55
+
54
56
  @property
55
57
  def primary_provider(self) -> Optional[str]:
56
58
  """Primary provider (first connected, or most recently synced)."""
57
59
  if not self.has_any_connection:
58
60
  return None
59
-
61
+
60
62
  # Preference order: plaid > teller > mx
61
63
  if self.plaid and self.plaid.connected:
62
64
  return "plaid"
@@ -70,17 +72,17 @@ class BankingConnectionStatus(BaseModel):
70
72
  def validate_plaid_token(access_token: str) -> bool:
71
73
  """
72
74
  Validate Plaid access token format.
73
-
75
+
74
76
  Args:
75
77
  access_token: Plaid access token to validate
76
-
78
+
77
79
  Returns:
78
80
  True if token format is valid
79
-
81
+
80
82
  Note:
81
83
  This only validates format, not that the token is active/unexpired.
82
84
  Use provider's API to verify token health.
83
-
85
+
84
86
  Example:
85
87
  >>> validate_plaid_token("access-sandbox-abc123")
86
88
  True
@@ -89,26 +91,26 @@ def validate_plaid_token(access_token: str) -> bool:
89
91
  """
90
92
  if not access_token:
91
93
  return False
92
-
94
+
93
95
  # Plaid tokens typically start with "access-{environment}-"
94
- pattern = r'^access-(sandbox|development|production)-[a-zA-Z0-9-_]+$'
96
+ pattern = r"^access-(sandbox|development|production)-[a-zA-Z0-9-_]+$"
95
97
  return bool(re.match(pattern, access_token))
96
98
 
97
99
 
98
100
  def validate_teller_token(access_token: str) -> bool:
99
101
  """
100
102
  Validate Teller access token format.
101
-
103
+
102
104
  Args:
103
105
  access_token: Teller access token to validate
104
-
106
+
105
107
  Returns:
106
108
  True if token format is valid
107
-
109
+
108
110
  Note:
109
111
  This only validates format, not that the token is active/unexpired.
110
112
  Use provider's API to verify token health.
111
-
113
+
112
114
  Example:
113
115
  >>> validate_teller_token("test_token_abc123")
114
116
  True
@@ -117,46 +119,46 @@ def validate_teller_token(access_token: str) -> bool:
117
119
  """
118
120
  if not access_token:
119
121
  return False
120
-
122
+
121
123
  # Teller tokens are typically alphanumeric with underscores
122
124
  # Sandbox tokens often start with "test_"
123
- pattern = r'^[a-zA-Z0-9_-]{10,}$'
125
+ pattern = r"^[a-zA-Z0-9_-]{10,}$"
124
126
  return bool(re.match(pattern, access_token))
125
127
 
126
128
 
127
129
  def validate_mx_token(access_token: str) -> bool:
128
130
  """
129
131
  Validate MX access token format.
130
-
132
+
131
133
  Args:
132
134
  access_token: MX access token to validate
133
-
135
+
134
136
  Returns:
135
137
  True if token format is valid
136
-
138
+
137
139
  Example:
138
140
  >>> validate_mx_token("USR-abc123")
139
141
  True
140
142
  """
141
143
  if not access_token:
142
144
  return False
143
-
145
+
144
146
  # MX tokens typically have a prefix like "USR-"
145
- pattern = r'^[A-Z]+-[a-zA-Z0-9-_]+$'
147
+ pattern = r"^[A-Z]+-[a-zA-Z0-9-_]+$"
146
148
  return bool(re.match(pattern, access_token))
147
149
 
148
150
 
149
151
  def validate_provider_token(provider: str, access_token: str) -> bool:
150
152
  """
151
153
  Validate token format for any provider.
152
-
154
+
153
155
  Args:
154
156
  provider: Provider name ("plaid", "teller", "mx")
155
157
  access_token: Token to validate
156
-
158
+
157
159
  Returns:
158
160
  True if token format is valid for the provider
159
-
161
+
160
162
  Example:
161
163
  >>> validate_provider_token("plaid", "access-sandbox-abc")
162
164
  True
@@ -168,29 +170,29 @@ def validate_provider_token(provider: str, access_token: str) -> bool:
168
170
  "teller": validate_teller_token,
169
171
  "mx": validate_mx_token,
170
172
  }
171
-
173
+
172
174
  validator = validators.get(provider.lower())
173
175
  if not validator:
174
176
  # Unknown provider - do basic validation
175
177
  return bool(access_token and len(access_token) > 10)
176
-
178
+
177
179
  return validator(access_token)
178
180
 
179
181
 
180
182
  def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnectionStatus:
181
183
  """
182
184
  Parse banking_providers JSON field into structured status.
183
-
185
+
184
186
  Args:
185
187
  banking_providers: Dictionary from User.banking_providers field
186
188
  Structure: {
187
189
  "plaid": {"access_token": "...", "item_id": "...", "connected_at": "..."},
188
190
  "teller": {"access_token": "...", "enrollment_id": "..."}
189
191
  }
190
-
192
+
191
193
  Returns:
192
194
  Structured status with connection info for all providers
193
-
195
+
194
196
  Example:
195
197
  >>> status = parse_banking_providers(user.banking_providers)
196
198
  >>> if status.has_any_connection:
@@ -199,10 +201,10 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
199
201
  ... print(f"Connected: {provider}")
200
202
  """
201
203
  status = BankingConnectionStatus()
202
-
204
+
203
205
  if not banking_providers:
204
206
  return status
205
-
207
+
206
208
  # Parse Plaid
207
209
  if "plaid" in banking_providers:
208
210
  plaid_data = banking_providers["plaid"]
@@ -216,7 +218,7 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
216
218
  is_healthy=plaid_data.get("is_healthy", True),
217
219
  error_message=plaid_data.get("error_message"),
218
220
  )
219
-
221
+
220
222
  # Parse Teller
221
223
  if "teller" in banking_providers:
222
224
  teller_data = banking_providers["teller"]
@@ -230,7 +232,7 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
230
232
  is_healthy=teller_data.get("is_healthy", True),
231
233
  error_message=teller_data.get("error_message"),
232
234
  )
233
-
235
+
234
236
  # Parse MX
235
237
  if "mx" in banking_providers:
236
238
  mx_data = banking_providers["mx"]
@@ -243,26 +245,28 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
243
245
  is_healthy=mx_data.get("is_healthy", True),
244
246
  error_message=mx_data.get("error_message"),
245
247
  )
246
-
247
- status.has_any_connection = any([
248
- status.plaid and status.plaid.connected,
249
- status.teller and status.teller.connected,
250
- status.mx and status.mx.connected,
251
- ])
252
-
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
+
253
257
  return status
254
258
 
255
259
 
256
260
  def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any]:
257
261
  """
258
262
  Sanitize connection status for API responses (removes access tokens).
259
-
263
+
260
264
  Args:
261
265
  status: Connection status with tokens
262
-
266
+
263
267
  Returns:
264
268
  Dictionary safe for API responses (no tokens)
265
-
269
+
266
270
  Example:
267
271
  >>> status = parse_banking_providers(user.banking_providers)
268
272
  >>> safe_data = sanitize_connection_status(status)
@@ -274,7 +278,7 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
274
278
  "primary_provider": status.primary_provider,
275
279
  "providers": {},
276
280
  }
277
-
281
+
278
282
  for provider_name in ["plaid", "teller", "mx"]:
279
283
  info = getattr(status, provider_name)
280
284
  if info:
@@ -289,7 +293,7 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
289
293
  "error_message": info.error_message,
290
294
  # NO access_token - this is sanitized
291
295
  }
292
-
296
+
293
297
  return result
294
298
 
295
299
 
@@ -300,15 +304,15 @@ def mark_connection_unhealthy(
300
304
  ) -> Dict[str, Any]:
301
305
  """
302
306
  Mark a provider connection as unhealthy (for error handling).
303
-
307
+
304
308
  Args:
305
309
  banking_providers: Current banking_providers dict
306
310
  provider: Provider name ("plaid", "teller", "mx")
307
311
  error_message: Error description
308
-
312
+
309
313
  Returns:
310
314
  Updated banking_providers dict
311
-
315
+
312
316
  Example:
313
317
  >>> try:
314
318
  ... accounts = await banking.get_accounts(access_token)
@@ -322,11 +326,11 @@ def mark_connection_unhealthy(
322
326
  """
323
327
  if provider not in banking_providers:
324
328
  return banking_providers
325
-
329
+
326
330
  banking_providers[provider]["is_healthy"] = False
327
331
  banking_providers[provider]["error_message"] = error_message
328
332
  banking_providers[provider]["error_at"] = datetime.now(timezone.utc).isoformat()
329
-
333
+
330
334
  return banking_providers
331
335
 
332
336
 
@@ -336,14 +340,14 @@ def mark_connection_healthy(
336
340
  ) -> Dict[str, Any]:
337
341
  """
338
342
  Mark a provider connection as healthy (after successful sync).
339
-
343
+
340
344
  Args:
341
345
  banking_providers: Current banking_providers dict
342
346
  provider: Provider name
343
-
347
+
344
348
  Returns:
345
349
  Updated banking_providers dict
346
-
350
+
347
351
  Example:
348
352
  >>> accounts = await banking.get_accounts(access_token)
349
353
  >>> user.banking_providers = mark_connection_healthy(
@@ -355,26 +359,28 @@ def mark_connection_healthy(
355
359
  """
356
360
  if provider not in banking_providers:
357
361
  return banking_providers
358
-
362
+
359
363
  banking_providers[provider]["is_healthy"] = True
360
364
  banking_providers[provider]["error_message"] = None
361
365
  banking_providers[provider]["last_synced_at"] = datetime.now(timezone.utc).isoformat()
362
-
366
+
363
367
  return banking_providers
364
368
 
365
369
 
366
- 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]]:
367
373
  """
368
374
  Get the primary access token and provider name.
369
-
375
+
370
376
  Returns the first healthy, connected provider in priority order: plaid > teller > mx.
371
-
377
+
372
378
  Args:
373
379
  banking_providers: Dictionary from User.banking_providers
374
-
380
+
375
381
  Returns:
376
382
  Tuple of (access_token, provider_name) or (None, None)
377
-
383
+
378
384
  Example:
379
385
  >>> access_token, provider = get_primary_access_token(user.banking_providers)
380
386
  >>> if access_token:
@@ -382,13 +388,13 @@ def get_primary_access_token(banking_providers: Dict[str, Any]) -> tuple[Optiona
382
388
  ... accounts = await banking.get_accounts(access_token)
383
389
  """
384
390
  status = parse_banking_providers(banking_providers)
385
-
391
+
386
392
  # Priority order: plaid > teller > mx
387
393
  for provider_name in ["plaid", "teller", "mx"]:
388
394
  info = getattr(status, provider_name)
389
395
  if info and info.connected and info.is_healthy and info.access_token:
390
396
  return info.access_token, provider_name
391
-
397
+
392
398
  return None, None
393
399
 
394
400
 
@@ -398,14 +404,14 @@ async def test_connection_health(
398
404
  ) -> tuple[bool, Optional[str]]:
399
405
  """
400
406
  Test if a banking connection is healthy by making a lightweight API call.
401
-
407
+
402
408
  Args:
403
409
  provider: Banking provider instance (from easy_banking())
404
410
  access_token: Access token to test
405
-
411
+
406
412
  Returns:
407
413
  Tuple of (is_healthy, error_message)
408
-
414
+
409
415
  Example:
410
416
  >>> banking = easy_banking(provider="plaid")
411
417
  >>> is_healthy, error = await test_connection_health(banking, access_token)
@@ -415,13 +421,13 @@ async def test_connection_health(
415
421
  try:
416
422
  # Try to fetch accounts (lightweight call)
417
423
  provider.accounts(access_token)
418
-
424
+
419
425
  # If we got here, connection is healthy
420
426
  return True, None
421
-
427
+
422
428
  except Exception as e:
423
429
  error_msg = str(e)
424
-
430
+
425
431
  # Check for common error patterns
426
432
  if "unauthorized" in error_msg.lower() or "invalid" in error_msg.lower():
427
433
  return False, "Token invalid or expired"
@@ -434,14 +440,14 @@ async def test_connection_health(
434
440
  def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bool:
435
441
  """
436
442
  Check if a provider token should be refreshed.
437
-
443
+
438
444
  Args:
439
445
  banking_providers: Current banking_providers dict
440
446
  provider: Provider name
441
-
447
+
442
448
  Returns:
443
449
  True if token should be refreshed
444
-
450
+
445
451
  Example:
446
452
  >>> if should_refresh_token(user.banking_providers, "plaid"):
447
453
  ... # Trigger token refresh flow
@@ -449,13 +455,13 @@ def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bo
449
455
  """
450
456
  if provider not in banking_providers:
451
457
  return False
452
-
458
+
453
459
  provider_data = banking_providers[provider]
454
-
460
+
455
461
  # Check if marked unhealthy
456
462
  if not provider_data.get("is_healthy", True):
457
463
  return True
458
-
464
+
459
465
  # Check last sync time
460
466
  last_synced_str = provider_data.get("last_synced_at")
461
467
  if last_synced_str:
@@ -465,7 +471,7 @@ def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bo
465
471
  days_since_sync = (datetime.now(timezone.utc) - last_synced).days
466
472
  if days_since_sync > 30:
467
473
  return True
468
-
474
+
469
475
  return False
470
476
 
471
477
 
@@ -473,15 +479,15 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
473
479
  """Parse datetime from various formats."""
474
480
  if not value:
475
481
  return None
476
-
482
+
477
483
  if isinstance(value, datetime):
478
484
  return value
479
-
485
+
480
486
  if isinstance(value, str):
481
487
  try:
482
488
  # Try ISO format
483
- return datetime.fromisoformat(value.replace('Z', '+00:00'))
489
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
484
490
  except (ValueError, AttributeError):
485
491
  pass
486
-
492
+
487
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(
@@ -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