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.
- fin_infra/analytics/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- 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 +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
fin_infra/banking/utils.py
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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"]
|
|
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(
|
|
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
|
-
|
|
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(
|
|
489
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
485
490
|
except (ValueError, AttributeError):
|
|
486
491
|
pass
|
|
487
|
-
|
|
492
|
+
|
|
488
493
|
return None
|
fin_infra/brokerage/__init__.py
CHANGED
|
@@ -123,7 +123,7 @@ def easy_brokerage(
|
|
|
123
123
|
)
|
|
124
124
|
|
|
125
125
|
else:
|
|
126
|
-
raise ValueError(f"Unknown brokerage provider: {provider_name}.
|
|
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
|
-
|
|
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)}")
|
fin_infra/budgets/tracker.py
CHANGED
|
@@ -155,7 +155,7 @@ class BudgetTracker:
|
|
|
155
155
|
BudgetType(type)
|
|
156
156
|
except ValueError:
|
|
157
157
|
raise ValueError(
|
|
158
|
-
f"Invalid budget type: {type}.
|
|
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
|
fin_infra/cashflows/__init__.py
CHANGED
|
@@ -22,10 +22,13 @@ Example usage:
|
|
|
22
22
|
rate = irr(cashflows)
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
from typing import
|
|
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",
|
|
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(
|
|
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
|
fin_infra/categorization/add.py
CHANGED
|
@@ -96,7 +96,8 @@ def add_categorization(
|
|
|
96
96
|
start_time = time.perf_counter()
|
|
97
97
|
|
|
98
98
|
try:
|
|
99
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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():
|
fin_infra/categorization/ease.py
CHANGED
|
@@ -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.
|
|
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
|