fin-infra 0.1.67__py3-none-any.whl → 0.1.68__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.67.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
fin_infra/categorization/ease.py
CHANGED
|
@@ -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
|
|
@@ -284,8 +284,8 @@ class LLMCategorizer:
|
|
|
284
284
|
Merchant: "{merchant_name}"
|
|
285
285
|
|
|
286
286
|
User context:
|
|
287
|
-
- Frequently shops at: {context[
|
|
288
|
-
- Top spending categories: {context[
|
|
287
|
+
- Frequently shops at: {context["top_merchants"]}
|
|
288
|
+
- Top spending categories: {context["top_categories"]}
|
|
289
289
|
|
|
290
290
|
Return JSON with category, confidence, and reasoning."""
|
|
291
291
|
else:
|
|
@@ -174,7 +174,7 @@ class ExperianProvider(CreditProvider):
|
|
|
174
174
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
175
175
|
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
176
176
|
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
179
179
|
# This log must be retained for at least 2 years per FCRA requirements
|
|
180
180
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
@@ -190,7 +190,7 @@ class ExperianProvider(CreditProvider):
|
|
|
190
190
|
"environment": self.environment,
|
|
191
191
|
"timestamp": timestamp,
|
|
192
192
|
"result": "pending",
|
|
193
|
-
}
|
|
193
|
+
},
|
|
194
194
|
)
|
|
195
195
|
|
|
196
196
|
try:
|
|
@@ -202,7 +202,7 @@ class ExperianProvider(CreditProvider):
|
|
|
202
202
|
|
|
203
203
|
# Parse response to CreditScore model
|
|
204
204
|
result = parse_credit_score(data, user_id=user_id)
|
|
205
|
-
|
|
205
|
+
|
|
206
206
|
# Log successful pull
|
|
207
207
|
fcra_audit_logger.info(
|
|
208
208
|
"FCRA_CREDIT_PULL_SUCCESS",
|
|
@@ -213,11 +213,11 @@ class ExperianProvider(CreditProvider):
|
|
|
213
213
|
"timestamp": timestamp,
|
|
214
214
|
"result": "success",
|
|
215
215
|
"score_returned": result.score is not None,
|
|
216
|
-
}
|
|
216
|
+
},
|
|
217
217
|
)
|
|
218
|
-
|
|
218
|
+
|
|
219
219
|
return result
|
|
220
|
-
|
|
220
|
+
|
|
221
221
|
except Exception as e:
|
|
222
222
|
# Log failed pull - still required for FCRA audit trail
|
|
223
223
|
fcra_audit_logger.warning(
|
|
@@ -229,7 +229,7 @@ class ExperianProvider(CreditProvider):
|
|
|
229
229
|
"timestamp": timestamp,
|
|
230
230
|
"result": "error",
|
|
231
231
|
"error_type": type(e).__name__,
|
|
232
|
-
}
|
|
232
|
+
},
|
|
233
233
|
)
|
|
234
234
|
raise
|
|
235
235
|
|
|
@@ -262,7 +262,7 @@ class ExperianProvider(CreditProvider):
|
|
|
262
262
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
263
263
|
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
264
264
|
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
265
|
-
|
|
265
|
+
|
|
266
266
|
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
267
267
|
# Full credit report pulls have stricter requirements than score-only pulls
|
|
268
268
|
# This log must be retained for at least 2 years per FCRA requirements
|
|
@@ -280,7 +280,7 @@ class ExperianProvider(CreditProvider):
|
|
|
280
280
|
"timestamp": timestamp,
|
|
281
281
|
"result": "pending",
|
|
282
282
|
"report_type": "full",
|
|
283
|
-
}
|
|
283
|
+
},
|
|
284
284
|
)
|
|
285
285
|
|
|
286
286
|
try:
|
|
@@ -292,7 +292,7 @@ class ExperianProvider(CreditProvider):
|
|
|
292
292
|
|
|
293
293
|
# Parse response to CreditReport model
|
|
294
294
|
result = parse_credit_report(data, user_id=user_id)
|
|
295
|
-
|
|
295
|
+
|
|
296
296
|
# Log successful pull
|
|
297
297
|
fcra_audit_logger.info(
|
|
298
298
|
"FCRA_CREDIT_PULL_SUCCESS",
|
|
@@ -304,11 +304,11 @@ class ExperianProvider(CreditProvider):
|
|
|
304
304
|
"result": "success",
|
|
305
305
|
"accounts_returned": len(result.accounts) if result.accounts else 0,
|
|
306
306
|
"inquiries_returned": len(result.inquiries) if result.inquiries else 0,
|
|
307
|
-
}
|
|
307
|
+
},
|
|
308
308
|
)
|
|
309
|
-
|
|
309
|
+
|
|
310
310
|
return result
|
|
311
|
-
|
|
311
|
+
|
|
312
312
|
except Exception as e:
|
|
313
313
|
# Log failed pull - still required for FCRA audit trail
|
|
314
314
|
fcra_audit_logger.warning(
|
|
@@ -320,7 +320,7 @@ class ExperianProvider(CreditProvider):
|
|
|
320
320
|
"timestamp": timestamp,
|
|
321
321
|
"result": "error",
|
|
322
322
|
"error_type": type(e).__name__,
|
|
323
|
-
}
|
|
323
|
+
},
|
|
324
324
|
)
|
|
325
325
|
raise
|
|
326
326
|
|
fin_infra/crypto/__init__.py
CHANGED
|
@@ -70,7 +70,7 @@ def easy_crypto(
|
|
|
70
70
|
return CoinGeckoCryptoData()
|
|
71
71
|
|
|
72
72
|
else:
|
|
73
|
-
raise ValueError(f"Unknown crypto data provider: {provider_name}.
|
|
73
|
+
raise ValueError(f"Unknown crypto data provider: {provider_name}. Supported: coingecko")
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def add_crypto_data(
|
fin_infra/documents/add.py
CHANGED
|
@@ -93,6 +93,7 @@ def add_documents(
|
|
|
93
93
|
# Import svc-infra base function to mount base endpoints (with fallback)
|
|
94
94
|
try:
|
|
95
95
|
from svc_infra.documents import add_documents as add_base_documents
|
|
96
|
+
|
|
96
97
|
HAS_SVC_INFRA_DOCUMENTS = True
|
|
97
98
|
except ImportError:
|
|
98
99
|
# Fallback for older svc-infra versions - skip base endpoints
|
|
@@ -109,11 +110,12 @@ def add_documents(
|
|
|
109
110
|
else:
|
|
110
111
|
# Legacy mode: mount basic endpoints inline (for svc-infra < 0.1.668)
|
|
111
112
|
import warnings
|
|
113
|
+
|
|
112
114
|
warnings.warn(
|
|
113
115
|
"svc_infra.documents not found. Using legacy document endpoints. "
|
|
114
116
|
"Please upgrade svc-infra to >=0.1.668 for full functionality.",
|
|
115
117
|
DeprecationWarning,
|
|
116
|
-
stacklevel=2
|
|
118
|
+
stacklevel=2,
|
|
117
119
|
)
|
|
118
120
|
|
|
119
121
|
# Step 2: Create financial document manager with OCR/AI capabilities
|
|
@@ -210,9 +212,7 @@ def add_documents(
|
|
|
210
212
|
```
|
|
211
213
|
"""
|
|
212
214
|
try:
|
|
213
|
-
return await manager.analyze(
|
|
214
|
-
document_id=document_id, force_refresh=force_refresh
|
|
215
|
-
)
|
|
215
|
+
return await manager.analyze(document_id=document_id, force_refresh=force_refresh)
|
|
216
216
|
except ValueError as e:
|
|
217
217
|
raise HTTPException(status_code=404, detail=str(e))
|
|
218
218
|
|
fin_infra/documents/ease.py
CHANGED
|
@@ -44,11 +44,12 @@ except ImportError:
|
|
|
44
44
|
# Fallback for older svc-infra versions without documents module
|
|
45
45
|
# This provides backward compatibility until svc-infra 0.1.668+ is published
|
|
46
46
|
import warnings
|
|
47
|
+
|
|
47
48
|
warnings.warn(
|
|
48
49
|
"svc_infra.documents not found. Using legacy implementation. "
|
|
49
50
|
"Please upgrade svc-infra to >=0.1.668 for layered architecture support.",
|
|
50
51
|
DeprecationWarning,
|
|
51
|
-
stacklevel=2
|
|
52
|
+
stacklevel=2,
|
|
52
53
|
)
|
|
53
54
|
BaseDocumentManager = object # type: ignore
|
|
54
55
|
|
|
@@ -65,10 +66,10 @@ class FinancialDocumentManager(BaseDocumentManager):
|
|
|
65
66
|
Inherits from svc-infra DocumentManager:
|
|
66
67
|
- upload(), download(), delete(), get(), list() for base document CRUD
|
|
67
68
|
- storage backend integration
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
Adds financial-specific methods:
|
|
70
71
|
- upload_financial(): Upload with DocumentType, tax_year, form_type
|
|
71
|
-
- extract_text(): OCR for tax forms
|
|
72
|
+
- extract_text(): OCR for tax forms
|
|
72
73
|
- analyze(): AI-powered financial insights
|
|
73
74
|
|
|
74
75
|
Attributes:
|
fin_infra/documents/models.py
CHANGED
|
@@ -52,17 +52,17 @@ class DocumentType(str, Enum):
|
|
|
52
52
|
class FinancialDocument(BaseDocument):
|
|
53
53
|
"""
|
|
54
54
|
Financial document extending base Document with financial-specific fields.
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
Inherits from svc-infra Document:
|
|
57
57
|
- id, user_id, filename, file_size, upload_date
|
|
58
58
|
- storage_path, content_type, checksum
|
|
59
59
|
- metadata (Dict[str, Any])
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
Adds financial-specific fields:
|
|
62
62
|
- type: DocumentType enum
|
|
63
63
|
- tax_year: Optional year for tax documents
|
|
64
64
|
- form_type: Optional form identifier (W-2, 1099, etc.)
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
Examples:
|
|
67
67
|
>>> # Tax document with W-2 form
|
|
68
68
|
>>> doc = FinancialDocument(
|
fin_infra/documents/ocr.py
CHANGED
|
@@ -46,7 +46,7 @@ async def extract_text(
|
|
|
46
46
|
Extract text from a document using OCR (uses svc-infra storage).
|
|
47
47
|
|
|
48
48
|
Args:
|
|
49
|
-
storage: Storage backend instance
|
|
49
|
+
storage: Storage backend instance
|
|
50
50
|
document_id: Document identifier
|
|
51
51
|
provider: OCR provider ("tesseract" or "textract")
|
|
52
52
|
force_refresh: Force re-extraction even if cached result exists
|
fin_infra/documents/storage.py
CHANGED
|
@@ -46,6 +46,7 @@ try:
|
|
|
46
46
|
list_documents as base_list_documents,
|
|
47
47
|
upload_document as base_upload_document,
|
|
48
48
|
)
|
|
49
|
+
|
|
49
50
|
HAS_SVC_INFRA_DOCUMENTS = True
|
|
50
51
|
except ImportError:
|
|
51
52
|
# Fallback for older svc-infra versions - use legacy implementation
|
|
@@ -153,7 +154,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
|
|
|
153
154
|
>>> doc = get_document("doc_abc123")
|
|
154
155
|
>>> if doc:
|
|
155
156
|
... print(doc.filename, doc.type, doc.tax_year)
|
|
156
|
-
|
|
157
|
+
|
|
157
158
|
Notes:
|
|
158
159
|
- Delegates to svc-infra.documents.get_document
|
|
159
160
|
- Converts base Document to FinancialDocument
|
fin_infra/exceptions.py
CHANGED
|
@@ -6,7 +6,7 @@ This module provides a consistent exception hierarchy across all fin-infra compo
|
|
|
6
6
|
- Validation errors (data validation, compliance)
|
|
7
7
|
- Calculation errors (financial calculations)
|
|
8
8
|
|
|
9
|
-
All exceptions inherit from FinInfraError, allowing users to catch all library
|
|
9
|
+
All exceptions inherit from FinInfraError, allowing users to catch all library
|
|
10
10
|
errors with a single except clause.
|
|
11
11
|
|
|
12
12
|
Example:
|
fin_infra/goals/management.py
CHANGED
|
@@ -597,10 +597,10 @@ Goal type: {goal_type}
|
|
|
597
597
|
Goal data: {goal}
|
|
598
598
|
|
|
599
599
|
CALCULATED VALUES (use these exactly, don't recalculate):
|
|
600
|
-
- Feasibility: {calc[
|
|
601
|
-
- Required monthly: ${calc[
|
|
600
|
+
- Feasibility: {calc["feasibility"]}
|
|
601
|
+
- Required monthly: ${calc["required_monthly"]:,.0f}
|
|
602
602
|
- Projected completion: {projected_date}
|
|
603
|
-
- Current progress: {calc[
|
|
603
|
+
- Current progress: {calc["current_progress"]:.1%}
|
|
604
604
|
|
|
605
605
|
Provide context and advice around these calculations. Suggest 2-3 alternative paths and 3-5 specific recommendations."""
|
|
606
606
|
|
fin_infra/insights/__init__.py
CHANGED
|
@@ -19,7 +19,7 @@ Example usage:
|
|
|
19
19
|
# Explicit provider
|
|
20
20
|
investments = easy_investments(provider="plaid")
|
|
21
21
|
holdings = await investments.get_holdings(access_token)
|
|
22
|
-
|
|
22
|
+
|
|
23
23
|
# Calculate metrics
|
|
24
24
|
allocation = investments.calculate_allocation(holdings)
|
|
25
25
|
metrics = investments.calculate_portfolio_metrics(holdings)
|
|
@@ -125,9 +125,7 @@ def easy_investments(
|
|
|
125
125
|
|
|
126
126
|
instance = SnapTradeInvestmentProvider(**config)
|
|
127
127
|
else:
|
|
128
|
-
raise ValueError(
|
|
129
|
-
f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'"
|
|
130
|
-
)
|
|
128
|
+
raise ValueError(f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'")
|
|
131
129
|
|
|
132
130
|
_provider_cache[cache_key] = instance
|
|
133
131
|
return instance
|
fin_infra/investments/add.py
CHANGED
|
@@ -39,12 +39,8 @@ class HoldingsRequest(BaseModel):
|
|
|
39
39
|
|
|
40
40
|
access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
|
|
41
41
|
user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
|
|
42
|
-
user_secret: Optional[str] = Field(
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
account_ids: Optional[list[str]] = Field(
|
|
46
|
-
None, description="Filter by specific account IDs"
|
|
47
|
-
)
|
|
42
|
+
user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
|
|
43
|
+
account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
|
|
48
44
|
|
|
49
45
|
|
|
50
46
|
class TransactionsRequest(BaseModel):
|
|
@@ -52,14 +48,10 @@ class TransactionsRequest(BaseModel):
|
|
|
52
48
|
|
|
53
49
|
access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
|
|
54
50
|
user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
|
|
55
|
-
user_secret: Optional[str] = Field(
|
|
56
|
-
None, description="SnapTrade user secret (SnapTrade only)"
|
|
57
|
-
)
|
|
51
|
+
user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
|
|
58
52
|
start_date: date = Field(..., description="Start date for transactions (YYYY-MM-DD)")
|
|
59
53
|
end_date: date = Field(..., description="End date for transactions (YYYY-MM-DD)")
|
|
60
|
-
account_ids: Optional[list[str]] = Field(
|
|
61
|
-
None, description="Filter by specific account IDs"
|
|
62
|
-
)
|
|
54
|
+
account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
|
|
63
55
|
|
|
64
56
|
|
|
65
57
|
class AccountsRequest(BaseModel):
|
|
@@ -67,9 +59,7 @@ class AccountsRequest(BaseModel):
|
|
|
67
59
|
|
|
68
60
|
access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
|
|
69
61
|
user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
|
|
70
|
-
user_secret: Optional[str] = Field(
|
|
71
|
-
None, description="SnapTrade user secret (SnapTrade only)"
|
|
72
|
-
)
|
|
62
|
+
user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
|
|
73
63
|
|
|
74
64
|
|
|
75
65
|
class AllocationRequest(BaseModel):
|
|
@@ -77,12 +67,8 @@ class AllocationRequest(BaseModel):
|
|
|
77
67
|
|
|
78
68
|
access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
|
|
79
69
|
user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
|
|
80
|
-
user_secret: Optional[str] = Field(
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
account_ids: Optional[list[str]] = Field(
|
|
84
|
-
None, description="Filter by specific account IDs"
|
|
85
|
-
)
|
|
70
|
+
user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
|
|
71
|
+
account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
|
|
86
72
|
|
|
87
73
|
|
|
88
74
|
class SecuritiesRequest(BaseModel):
|
|
@@ -90,9 +76,7 @@ class SecuritiesRequest(BaseModel):
|
|
|
90
76
|
|
|
91
77
|
access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
|
|
92
78
|
user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
|
|
93
|
-
user_secret: Optional[str] = Field(
|
|
94
|
-
None, description="SnapTrade user secret (SnapTrade only)"
|
|
95
|
-
)
|
|
79
|
+
user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
|
|
96
80
|
security_ids: list[str] = Field(..., description="List of security IDs to retrieve")
|
|
97
81
|
|
|
98
82
|
|
|
@@ -155,13 +139,13 @@ def add_investments(
|
|
|
155
139
|
- Validates user's JWT/session cookie
|
|
156
140
|
- Ensures user is logged into YOUR application
|
|
157
141
|
- Provides identity.user with authenticated user
|
|
158
|
-
|
|
142
|
+
|
|
159
143
|
2. Provider Access (endpoint logic): Handled by these endpoints
|
|
160
144
|
- Gets Plaid/SnapTrade access token for the provider
|
|
161
145
|
- Auto-resolves from identity.user.banking_providers
|
|
162
146
|
- Can be overridden with explicit token in request body
|
|
163
147
|
- Used to call external provider APIs (Plaid, SnapTrade)
|
|
164
|
-
|
|
148
|
+
|
|
165
149
|
POST requests are used (not GET) because:
|
|
166
150
|
1. Provider credentials should not be in URL query parameters
|
|
167
151
|
2. Request bodies are more suitable for sensitive data
|
|
@@ -173,7 +157,7 @@ def add_investments(
|
|
|
173
157
|
|
|
174
158
|
# 2. Store on app state
|
|
175
159
|
app.state.investment_provider = provider
|
|
176
|
-
|
|
160
|
+
|
|
177
161
|
# 3. Capture provider in local variable for closure
|
|
178
162
|
investment_provider = provider
|
|
179
163
|
|
|
@@ -204,20 +188,19 @@ def add_investments(
|
|
|
204
188
|
access_token = f"{request.user_id}:{request.user_secret}"
|
|
205
189
|
else:
|
|
206
190
|
# Auto-resolve from authenticated user (user_router guarantees identity.user exists)
|
|
207
|
-
banking_providers = getattr(identity.user,
|
|
191
|
+
banking_providers = getattr(identity.user, "banking_providers", {})
|
|
208
192
|
if not banking_providers or "plaid" not in banking_providers:
|
|
209
193
|
raise HTTPException(
|
|
210
194
|
status_code=400,
|
|
211
|
-
detail="No Plaid connection found. Please connect your accounts first."
|
|
195
|
+
detail="No Plaid connection found. Please connect your accounts first.",
|
|
212
196
|
)
|
|
213
|
-
|
|
197
|
+
|
|
214
198
|
access_token = banking_providers["plaid"].get("access_token")
|
|
215
199
|
if not access_token:
|
|
216
200
|
raise HTTPException(
|
|
217
|
-
status_code=400,
|
|
218
|
-
detail="No access token found. Please reconnect your accounts."
|
|
201
|
+
status_code=400, detail="No access token found. Please reconnect your accounts."
|
|
219
202
|
)
|
|
220
|
-
|
|
203
|
+
|
|
221
204
|
# Call provider with resolved token
|
|
222
205
|
try:
|
|
223
206
|
holdings = await investment_provider.get_holdings(
|
|
@@ -236,7 +219,9 @@ def add_investments(
|
|
|
236
219
|
summary="List Transactions",
|
|
237
220
|
description="Fetch investment transactions (buy, sell, dividend, etc.) within date range",
|
|
238
221
|
)
|
|
239
|
-
async def get_transactions(
|
|
222
|
+
async def get_transactions(
|
|
223
|
+
request: TransactionsRequest, identity: Identity
|
|
224
|
+
) -> list[InvestmentTransaction]:
|
|
240
225
|
"""
|
|
241
226
|
Retrieve investment transactions for authenticated user's accounts.
|
|
242
227
|
|
|
@@ -256,18 +241,17 @@ def add_investments(
|
|
|
256
241
|
elif request.user_id and request.user_secret:
|
|
257
242
|
access_token = f"{request.user_id}:{request.user_secret}"
|
|
258
243
|
else:
|
|
259
|
-
banking_providers = getattr(identity.user,
|
|
244
|
+
banking_providers = getattr(identity.user, "banking_providers", {})
|
|
260
245
|
if not banking_providers or "plaid" not in banking_providers:
|
|
261
246
|
raise HTTPException(
|
|
262
247
|
status_code=400,
|
|
263
|
-
detail="No Plaid connection found. Please connect your accounts first."
|
|
248
|
+
detail="No Plaid connection found. Please connect your accounts first.",
|
|
264
249
|
)
|
|
265
|
-
|
|
250
|
+
|
|
266
251
|
access_token = banking_providers["plaid"].get("access_token")
|
|
267
252
|
if not access_token:
|
|
268
253
|
raise HTTPException(
|
|
269
|
-
status_code=400,
|
|
270
|
-
detail="No access token found. Please reconnect your accounts."
|
|
254
|
+
status_code=400, detail="No access token found. Please reconnect your accounts."
|
|
271
255
|
)
|
|
272
256
|
|
|
273
257
|
try:
|
|
@@ -301,18 +285,17 @@ def add_investments(
|
|
|
301
285
|
elif request.user_id and request.user_secret:
|
|
302
286
|
access_token = f"{request.user_id}:{request.user_secret}"
|
|
303
287
|
else:
|
|
304
|
-
banking_providers = getattr(identity.user,
|
|
288
|
+
banking_providers = getattr(identity.user, "banking_providers", {})
|
|
305
289
|
if not banking_providers or "plaid" not in banking_providers:
|
|
306
290
|
raise HTTPException(
|
|
307
291
|
status_code=400,
|
|
308
|
-
detail="No Plaid connection found. Please connect your accounts first."
|
|
292
|
+
detail="No Plaid connection found. Please connect your accounts first.",
|
|
309
293
|
)
|
|
310
|
-
|
|
294
|
+
|
|
311
295
|
access_token = banking_providers["plaid"].get("access_token")
|
|
312
296
|
if not access_token:
|
|
313
297
|
raise HTTPException(
|
|
314
|
-
status_code=400,
|
|
315
|
-
detail="No access token found. Please reconnect your accounts."
|
|
298
|
+
status_code=400, detail="No access token found. Please reconnect your accounts."
|
|
316
299
|
)
|
|
317
300
|
|
|
318
301
|
try:
|
|
@@ -343,20 +326,19 @@ def add_investments(
|
|
|
343
326
|
elif request.user_id and request.user_secret:
|
|
344
327
|
access_token = f"{request.user_id}:{request.user_secret}"
|
|
345
328
|
else:
|
|
346
|
-
banking_providers = getattr(identity.user,
|
|
329
|
+
banking_providers = getattr(identity.user, "banking_providers", {})
|
|
347
330
|
if not banking_providers or "plaid" not in banking_providers:
|
|
348
331
|
raise HTTPException(
|
|
349
332
|
status_code=400,
|
|
350
|
-
detail="No Plaid connection found. Please connect your accounts first."
|
|
333
|
+
detail="No Plaid connection found. Please connect your accounts first.",
|
|
351
334
|
)
|
|
352
|
-
|
|
335
|
+
|
|
353
336
|
access_token = banking_providers["plaid"].get("access_token")
|
|
354
337
|
if not access_token:
|
|
355
338
|
raise HTTPException(
|
|
356
|
-
status_code=400,
|
|
357
|
-
detail="No access token found. Please reconnect your accounts."
|
|
339
|
+
status_code=400, detail="No access token found. Please reconnect your accounts."
|
|
358
340
|
)
|
|
359
|
-
|
|
341
|
+
|
|
360
342
|
# Fetch holdings
|
|
361
343
|
try:
|
|
362
344
|
holdings = await investment_provider.get_holdings(
|
|
@@ -367,7 +349,7 @@ def add_investments(
|
|
|
367
349
|
raise HTTPException(status_code=401, detail=str(e))
|
|
368
350
|
except Exception as e:
|
|
369
351
|
raise HTTPException(status_code=500, detail=f"Failed to fetch allocation: {e}")
|
|
370
|
-
|
|
352
|
+
|
|
371
353
|
# Calculate allocation using base provider helper
|
|
372
354
|
allocation = investment_provider.calculate_allocation(holdings)
|
|
373
355
|
return allocation
|
|
@@ -390,18 +372,17 @@ def add_investments(
|
|
|
390
372
|
elif request.user_id and request.user_secret:
|
|
391
373
|
access_token = f"{request.user_id}:{request.user_secret}"
|
|
392
374
|
else:
|
|
393
|
-
banking_providers = getattr(identity.user,
|
|
375
|
+
banking_providers = getattr(identity.user, "banking_providers", {})
|
|
394
376
|
if not banking_providers or "plaid" not in banking_providers:
|
|
395
377
|
raise HTTPException(
|
|
396
378
|
status_code=400,
|
|
397
|
-
detail="No Plaid connection found. Please connect your accounts first."
|
|
379
|
+
detail="No Plaid connection found. Please connect your accounts first.",
|
|
398
380
|
)
|
|
399
|
-
|
|
381
|
+
|
|
400
382
|
access_token = banking_providers["plaid"].get("access_token")
|
|
401
383
|
if not access_token:
|
|
402
384
|
raise HTTPException(
|
|
403
|
-
status_code=400,
|
|
404
|
-
detail="No access token found. Please reconnect your accounts."
|
|
385
|
+
status_code=400, detail="No access token found. Please reconnect your accounts."
|
|
405
386
|
)
|
|
406
387
|
|
|
407
388
|
try:
|
fin_infra/investments/ease.py
CHANGED
|
@@ -24,13 +24,13 @@ def easy_investments(
|
|
|
24
24
|
|
|
25
25
|
Provider Selection Guide:
|
|
26
26
|
**Most apps should use BOTH providers for complete coverage:**
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
- **Plaid**: Traditional investment accounts (401k, IRA, bank brokerage)
|
|
29
29
|
- Coverage: 15,000+ institutions
|
|
30
30
|
- Best for: Employer retirement accounts, bank-connected investments
|
|
31
31
|
- Data freshness: Daily updates (usually overnight)
|
|
32
32
|
- Authentication: access_token from Plaid Link
|
|
33
|
-
|
|
33
|
+
|
|
34
34
|
- **SnapTrade**: Retail brokerage accounts (E*TRADE, Wealthsimple, Robinhood)
|
|
35
35
|
- Coverage: 125M+ accounts, 70+ brokerages
|
|
36
36
|
- Best for: User's EXISTING retail brokerage accounts
|
|
@@ -118,9 +118,7 @@ def easy_investments(
|
|
|
118
118
|
|
|
119
119
|
# Validate provider
|
|
120
120
|
if detected_provider not in ("plaid", "snaptrade"):
|
|
121
|
-
raise ValueError(
|
|
122
|
-
f"Invalid provider: {detected_provider}. Must be 'plaid' or 'snaptrade'."
|
|
123
|
-
)
|
|
121
|
+
raise ValueError(f"Invalid provider: {detected_provider}. Must be 'plaid' or 'snaptrade'.")
|
|
124
122
|
|
|
125
123
|
# Instantiate provider
|
|
126
124
|
if detected_provider == "plaid":
|
|
@@ -193,8 +191,7 @@ def _create_plaid_provider(**config: Any) -> InvestmentProvider:
|
|
|
193
191
|
valid_envs = ("sandbox", "development", "production")
|
|
194
192
|
if environment not in valid_envs:
|
|
195
193
|
raise ValueError(
|
|
196
|
-
f"Invalid Plaid environment: {environment}. "
|
|
197
|
-
f"Must be one of: {', '.join(valid_envs)}"
|
|
194
|
+
f"Invalid Plaid environment: {environment}. Must be one of: {', '.join(valid_envs)}"
|
|
198
195
|
)
|
|
199
196
|
|
|
200
197
|
return PlaidInvestmentProvider(
|
|
@@ -235,7 +232,9 @@ def _create_snaptrade_provider(**config: Any) -> InvestmentProvider:
|
|
|
235
232
|
)
|
|
236
233
|
|
|
237
234
|
# Ensure base_url is a string (default is set in SnapTradeInvestmentProvider)
|
|
238
|
-
resolved_base_url: str =
|
|
235
|
+
resolved_base_url: str = (
|
|
236
|
+
base_url if isinstance(base_url, str) else "https://api.snaptrade.com/api/v1"
|
|
237
|
+
)
|
|
239
238
|
|
|
240
239
|
return SnapTradeInvestmentProvider(
|
|
241
240
|
client_id=client_id,
|