fin-infra 0.1.66__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.
- fin_infra/analytics/add.py +9 -11
- fin_infra/analytics/portfolio.py +12 -18
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +3 -1
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +88 -82
- fin_infra/brokerage/__init__.py +1 -1
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/categorization/ease.py +2 -3
- fin_infra/categorization/llm_layer.py +2 -2
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/credit/experian/provider.py +14 -14
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/documents/add.py +4 -4
- 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/management.py +3 -3
- fin_infra/insights/__init__.py +0 -1
- fin_infra/investments/__init__.py +2 -4
- fin_infra/investments/add.py +37 -56
- fin_infra/investments/ease.py +7 -8
- fin_infra/investments/models.py +29 -17
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +19 -29
- fin_infra/investments/providers/snaptrade.py +18 -36
- fin_infra/markets/__init__.py +4 -2
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +2 -1
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +2 -2
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/normalization/__init__.py +3 -1
- fin_infra/providers/banking/plaid_client.py +16 -16
- fin_infra/providers/base.py +5 -5
- fin_infra/providers/brokerage/alpaca.py +2 -2
- fin_infra/providers/market/ccxt_crypto.py +4 -1
- fin_infra/recurring/add.py +3 -1
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/normalizer.py +1 -1
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/tax/__init__.py +1 -1
- {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
- {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
- {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/add.py
CHANGED
|
@@ -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,
|
fin_infra/analytics/portfolio.py
CHANGED
|
@@ -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(
|
|
576
|
-
holding.cost_basis if holding.cost_basis is not None else 0
|
|
577
|
-
|
|
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 =
|
|
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
|
|
fin_infra/analytics/savings.py
CHANGED
|
@@ -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:
|
|
80
|
+
f"Invalid period '{period}'. Must be one of: {', '.join([p.value for p in Period])}"
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
try:
|
fin_infra/analytics/spending.py
CHANGED
|
@@ -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
|
|
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
|
fin_infra/banking/history.py
CHANGED
|
@@ -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,
|
fin_infra/banking/utils.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
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(
|
|
489
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
484
490
|
except (ValueError, AttributeError):
|
|
485
491
|
pass
|
|
486
|
-
|
|
492
|
+
|
|
487
493
|
return None
|
fin_infra/brokerage/__init__.py
CHANGED
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
|