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.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +30 -32
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +19 -26
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +33 -31
- fin_infra/banking/history.py +11 -12
- fin_infra/banking/utils.py +116 -110
- fin_infra/brokerage/__init__.py +27 -27
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +3 -3
- fin_infra/budgets/tracker.py +4 -5
- fin_infra/cashflows/__init__.py +8 -10
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +17 -19
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +21 -18
- fin_infra/categorization/llm_layer.py +10 -10
- fin_infra/categorization/models.py +1 -1
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +13 -22
- fin_infra/chat/planning.py +57 -1
- fin_infra/cli/cmds/scaffold_cmds.py +11 -12
- fin_infra/clients/__init__.py +23 -1
- fin_infra/clients/base.py +1 -1
- fin_infra/clients/plaid.py +2 -2
- fin_infra/compliance/__init__.py +7 -6
- fin_infra/credit/add.py +7 -7
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +19 -19
- fin_infra/crypto/__init__.py +8 -10
- fin_infra/crypto/insights.py +5 -6
- fin_infra/documents/add.py +11 -13
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +18 -17
- fin_infra/documents/models.py +7 -7
- fin_infra/documents/ocr.py +8 -8
- fin_infra/documents/storage.py +23 -14
- fin_infra/exceptions.py +1 -2
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +36 -36
- fin_infra/goals/funding.py +4 -6
- fin_infra/goals/management.py +6 -7
- fin_infra/goals/milestones.py +2 -3
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +12 -10
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +14 -9
- fin_infra/investments/add.py +53 -73
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +135 -69
- fin_infra/investments/providers/base.py +9 -15
- fin_infra/investments/providers/plaid.py +70 -55
- fin_infra/investments/providers/snaptrade.py +35 -53
- fin_infra/markets/__init__.py +16 -11
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +4 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +9 -6
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -5
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +17 -15
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +23 -22
- fin_infra/providers/banking/teller_client.py +14 -7
- fin_infra/providers/base.py +131 -14
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +25 -4
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +23 -8
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +8 -8
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +24 -12
- fin_infra/recurring/detector.py +8 -8
- fin_infra/recurring/detectors_llm.py +14 -13
- fin_infra/recurring/ease.py +3 -5
- fin_infra/recurring/insights.py +20 -19
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +11 -10
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +5 -5
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +2 -2
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +5 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.62.dist-info/RECORD +0 -180
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
fin_infra/banking/utils.py
CHANGED
|
@@ -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
|
|
12
|
-
from typing import Any,
|
|
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:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
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:
|
|
40
|
-
teller:
|
|
41
|
-
mx:
|
|
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) ->
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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) ->
|
|
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"]
|
|
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:
|
|
302
|
+
banking_providers: dict[str, Any],
|
|
299
303
|
provider: str,
|
|
300
304
|
error_message: str,
|
|
301
|
-
) ->
|
|
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(
|
|
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:
|
|
339
|
+
banking_providers: dict[str, Any],
|
|
336
340
|
provider: str,
|
|
337
|
-
) ->
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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) ->
|
|
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(
|
|
490
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
485
491
|
except (ValueError, AttributeError):
|
|
486
492
|
pass
|
|
487
|
-
|
|
493
|
+
|
|
488
494
|
return None
|