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