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
|
@@ -15,13 +15,13 @@ Expected performance:
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
19
20
|
from pydantic import BaseModel, Field
|
|
20
21
|
|
|
21
22
|
# ai-infra imports
|
|
22
23
|
try:
|
|
23
24
|
from ai_infra.llm import LLM
|
|
24
|
-
from ai_infra.llm.providers import Providers
|
|
25
25
|
except ImportError:
|
|
26
26
|
raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
|
|
27
27
|
|
|
@@ -41,7 +41,7 @@ class CategoryPrediction(BaseModel):
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
# Few-shot examples (20 diverse merchants covering all major categories)
|
|
44
|
-
FEW_SHOT_EXAMPLES:
|
|
44
|
+
FEW_SHOT_EXAMPLES: list[tuple[str, str, str]] = [
|
|
45
45
|
# Food & Dining (5 examples)
|
|
46
46
|
("STARBUCKS #1234", "Coffee Shops", "Popular coffee shop chain"),
|
|
47
47
|
("MCDONALD'S", "Fast Food", "Fast food restaurant"),
|
|
@@ -158,7 +158,7 @@ class LLMCategorizer:
|
|
|
158
158
|
async def categorize(
|
|
159
159
|
self,
|
|
160
160
|
merchant_name: str,
|
|
161
|
-
user_id:
|
|
161
|
+
user_id: str | None = None,
|
|
162
162
|
) -> CategoryPrediction:
|
|
163
163
|
"""
|
|
164
164
|
Categorize merchant using LLM.
|
|
@@ -210,14 +210,14 @@ class LLMCategorizer:
|
|
|
210
210
|
async def _call_llm(
|
|
211
211
|
self,
|
|
212
212
|
merchant_name: str,
|
|
213
|
-
user_id:
|
|
213
|
+
user_id: str | None = None,
|
|
214
214
|
) -> CategoryPrediction:
|
|
215
215
|
"""Call LLM API with structured output."""
|
|
216
216
|
# Build user message
|
|
217
217
|
user_message = self._build_user_message(merchant_name, user_id)
|
|
218
218
|
|
|
219
219
|
# Call LLM with retry logic
|
|
220
|
-
extra = {
|
|
220
|
+
extra: dict[str, Any] = {
|
|
221
221
|
"retry": {
|
|
222
222
|
"max_tries": 3,
|
|
223
223
|
"base": 0.5,
|
|
@@ -245,7 +245,7 @@ class LLMCategorizer:
|
|
|
245
245
|
f"Must be one of {len(valid_categories)} valid categories."
|
|
246
246
|
)
|
|
247
247
|
|
|
248
|
-
return response
|
|
248
|
+
return cast("CategoryPrediction", response)
|
|
249
249
|
|
|
250
250
|
def _build_system_prompt(self) -> str:
|
|
251
251
|
"""Build system prompt with few-shot examples (reused across all requests)."""
|
|
@@ -270,7 +270,7 @@ class LLMCategorizer:
|
|
|
270
270
|
def _build_user_message(
|
|
271
271
|
self,
|
|
272
272
|
merchant_name: str,
|
|
273
|
-
user_id:
|
|
273
|
+
user_id: str | None = None,
|
|
274
274
|
) -> str:
|
|
275
275
|
"""Build user message with optional personalization."""
|
|
276
276
|
if self.enable_personalization and user_id:
|
|
@@ -285,8 +285,8 @@ class LLMCategorizer:
|
|
|
285
285
|
Merchant: "{merchant_name}"
|
|
286
286
|
|
|
287
287
|
User context:
|
|
288
|
-
- Frequently shops at: {context[
|
|
289
|
-
- Top spending categories: {context[
|
|
288
|
+
- Frequently shops at: {context["top_merchants"]}
|
|
289
|
+
- Top spending categories: {context["top_categories"]}
|
|
290
290
|
|
|
291
291
|
Return JSON with category, confidence, and reasoning."""
|
|
292
292
|
else:
|
|
@@ -6,12 +6,10 @@ Organized by category for maintainability.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from .models import CategoryRule
|
|
12
11
|
from .taxonomy import Category
|
|
13
12
|
|
|
14
|
-
|
|
15
13
|
# ===== HELPER FUNCTIONS (defined first) =====
|
|
16
14
|
|
|
17
15
|
|
|
@@ -306,7 +304,7 @@ COMPILED_REGEX_RULES = [
|
|
|
306
304
|
# ===== PUBLIC FUNCTIONS =====
|
|
307
305
|
|
|
308
306
|
|
|
309
|
-
def get_exact_match(merchant: str) ->
|
|
307
|
+
def get_exact_match(merchant: str) -> Category | None:
|
|
310
308
|
"""
|
|
311
309
|
Get category by exact match.
|
|
312
310
|
|
|
@@ -320,7 +318,7 @@ def get_exact_match(merchant: str) -> Optional[Category]:
|
|
|
320
318
|
return EXACT_RULES_NORMALIZED.get(normalized)
|
|
321
319
|
|
|
322
320
|
|
|
323
|
-
def get_regex_match(merchant: str) ->
|
|
321
|
+
def get_regex_match(merchant: str) -> tuple[Category, int] | None:
|
|
324
322
|
"""
|
|
325
323
|
Get category by regex match.
|
|
326
324
|
|
|
@@ -12,7 +12,7 @@ Total: 56 leaf categories
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from enum import Enum
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
from pydantic import BaseModel
|
|
17
17
|
|
|
18
18
|
|
|
@@ -315,7 +315,7 @@ def get_category_group(category: Category) -> CategoryGroup:
|
|
|
315
315
|
return CATEGORY_GROUPS.get(category, CategoryGroup.UNCATEGORIZED)
|
|
316
316
|
|
|
317
317
|
|
|
318
|
-
def get_category_metadata(category: Category) ->
|
|
318
|
+
def get_category_metadata(category: Category) -> CategoryMetadata | None:
|
|
319
319
|
"""Get metadata for a category."""
|
|
320
320
|
return CATEGORY_METADATA.get(category)
|
|
321
321
|
|
fin_infra/chat/__init__.py
CHANGED
|
@@ -28,15 +28,20 @@ Example:
|
|
|
28
28
|
)
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from fastapi import FastAPI
|
|
35
|
+
|
|
36
|
+
from fin_infra.chat.ease import easy_financial_conversation
|
|
31
37
|
from fin_infra.chat.planning import (
|
|
32
|
-
|
|
33
|
-
ConversationResponse,
|
|
38
|
+
SENSITIVE_PATTERNS,
|
|
34
39
|
ConversationContext,
|
|
40
|
+
ConversationResponse,
|
|
35
41
|
Exchange,
|
|
42
|
+
FinancialPlanningConversation,
|
|
36
43
|
is_sensitive_question,
|
|
37
|
-
SENSITIVE_PATTERNS,
|
|
38
44
|
)
|
|
39
|
-
from fin_infra.chat.ease import easy_financial_conversation
|
|
40
45
|
|
|
41
46
|
__all__ = [
|
|
42
47
|
"FinancialPlanningConversation",
|
|
@@ -51,7 +56,7 @@ __all__ = [
|
|
|
51
56
|
|
|
52
57
|
|
|
53
58
|
def add_financial_conversation(
|
|
54
|
-
app: "FastAPI",
|
|
59
|
+
app: "FastAPI",
|
|
55
60
|
*,
|
|
56
61
|
prefix: str = "/chat",
|
|
57
62
|
conversation: FinancialPlanningConversation | None = None,
|
|
@@ -110,16 +115,11 @@ def add_financial_conversation(
|
|
|
110
115
|
- Includes financial advice disclaimer in all responses
|
|
111
116
|
- Logs all LLM calls for compliance (via svc-infra logging)
|
|
112
117
|
"""
|
|
113
|
-
from typing import TYPE_CHECKING, Any
|
|
114
|
-
|
|
115
|
-
if TYPE_CHECKING:
|
|
116
|
-
from fastapi import FastAPI
|
|
117
|
-
|
|
118
118
|
from pydantic import BaseModel, Field
|
|
119
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
119
120
|
|
|
120
121
|
# Import svc-infra user router (requires auth)
|
|
121
122
|
from svc_infra.api.fastapi.dual.protected import user_router
|
|
122
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
123
123
|
|
|
124
124
|
# Auto-create conversation if not provided
|
|
125
125
|
if conversation is None:
|
|
@@ -149,15 +149,6 @@ def add_financial_conversation(
|
|
|
149
149
|
# TODO: Get user_id from svc-infra auth context
|
|
150
150
|
user_id = "demo_user" # Placeholder
|
|
151
151
|
|
|
152
|
-
# Check for sensitive content
|
|
153
|
-
if is_sensitive_question(request.question):
|
|
154
|
-
return ConversationResponse(
|
|
155
|
-
answer="I cannot process requests containing sensitive information like SSNs, passwords, or account numbers. Please rephrase your question without this information.",
|
|
156
|
-
follow_up_questions=[],
|
|
157
|
-
conversation_id=f"{user_id}_denied",
|
|
158
|
-
disclaimer="This is an automated safety response.",
|
|
159
|
-
)
|
|
160
|
-
|
|
161
152
|
# Ask conversation
|
|
162
153
|
response = await conversation.ask(
|
|
163
154
|
user_id=user_id,
|
|
@@ -173,7 +164,7 @@ def add_financial_conversation(
|
|
|
173
164
|
# TODO: Get user_id from svc-infra auth context
|
|
174
165
|
user_id = "demo_user"
|
|
175
166
|
context = await conversation._get_context(user_id)
|
|
176
|
-
return context.
|
|
167
|
+
return context.previous_exchanges if context else []
|
|
177
168
|
|
|
178
169
|
@router.delete("/history")
|
|
179
170
|
async def clear_history():
|
|
@@ -198,6 +189,6 @@ def add_financial_conversation(
|
|
|
198
189
|
# Store on app.state for programmatic access
|
|
199
190
|
app.state.financial_conversation = conversation
|
|
200
191
|
|
|
201
|
-
print(f"
|
|
192
|
+
print(f"Financial chat enabled (AI-powered Q&A with {provider})")
|
|
202
193
|
|
|
203
194
|
return conversation
|
fin_infra/chat/planning.py
CHANGED
|
@@ -54,7 +54,6 @@ from typing import Any
|
|
|
54
54
|
|
|
55
55
|
from pydantic import BaseModel, Field
|
|
56
56
|
|
|
57
|
-
|
|
58
57
|
# ============================================================================
|
|
59
58
|
# Pydantic Schemas (Structured Output)
|
|
60
59
|
# ============================================================================
|
|
@@ -338,8 +337,65 @@ class FinancialPlanningConversation:
|
|
|
338
337
|
# Save updated context (24h TTL)
|
|
339
338
|
await self._save_context(context)
|
|
340
339
|
|
|
340
|
+
# Track latest session id for convenience endpoints (history/clear).
|
|
341
|
+
# Best-effort: failures here must not break the chat response.
|
|
342
|
+
try:
|
|
343
|
+
await self.cache.set(
|
|
344
|
+
self._latest_session_key(user_id),
|
|
345
|
+
context.session_id,
|
|
346
|
+
ttl=86400,
|
|
347
|
+
)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
341
351
|
return response
|
|
342
352
|
|
|
353
|
+
# ---------------------------------------------------------------------
|
|
354
|
+
# Backward-compatible context helpers
|
|
355
|
+
# ---------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def _latest_session_key(self, user_id: str) -> str:
|
|
358
|
+
return f"fin_infra:conversation_latest_session:{user_id}"
|
|
359
|
+
|
|
360
|
+
async def _get_latest_session_id(self, user_id: str) -> str | None:
|
|
361
|
+
try:
|
|
362
|
+
value = await self.cache.get(self._latest_session_key(user_id))
|
|
363
|
+
except Exception:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
if value is None:
|
|
367
|
+
return None
|
|
368
|
+
if isinstance(value, bytes):
|
|
369
|
+
try:
|
|
370
|
+
return value.decode("utf-8")
|
|
371
|
+
except Exception:
|
|
372
|
+
return None
|
|
373
|
+
if isinstance(value, str):
|
|
374
|
+
return value
|
|
375
|
+
return str(value)
|
|
376
|
+
|
|
377
|
+
async def _get_context(
|
|
378
|
+
self, user_id: str, session_id: str | None = None
|
|
379
|
+
) -> ConversationContext | None:
|
|
380
|
+
if session_id is None:
|
|
381
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
382
|
+
if session_id is None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
return await self._load_context(user_id=user_id, session_id=session_id)
|
|
386
|
+
|
|
387
|
+
async def _clear_context(self, user_id: str, session_id: str | None = None) -> None:
|
|
388
|
+
if session_id is None:
|
|
389
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
390
|
+
|
|
391
|
+
if session_id is not None:
|
|
392
|
+
await self.clear_session(user_id=user_id, session_id=session_id)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
await self.cache.delete(self._latest_session_key(user_id))
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
|
|
343
399
|
async def _load_context(
|
|
344
400
|
self,
|
|
345
401
|
user_id: str,
|
|
@@ -12,7 +12,6 @@ Usage:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Optional
|
|
16
15
|
|
|
17
16
|
import click
|
|
18
17
|
import typer
|
|
@@ -54,47 +53,47 @@ def cmd_scaffold(
|
|
|
54
53
|
"--overwrite/--no-overwrite",
|
|
55
54
|
help="Overwrite existing files",
|
|
56
55
|
),
|
|
57
|
-
models_filename:
|
|
56
|
+
models_filename: str | None = typer.Option(
|
|
58
57
|
None,
|
|
59
58
|
"--models-filename",
|
|
60
59
|
help="Custom filename for models (default: {domain}.py)",
|
|
61
60
|
),
|
|
62
|
-
schemas_filename:
|
|
61
|
+
schemas_filename: str | None = typer.Option(
|
|
63
62
|
None,
|
|
64
63
|
"--schemas-filename",
|
|
65
64
|
help="Custom filename for schemas (default: {domain}_schemas.py)",
|
|
66
65
|
),
|
|
67
|
-
repository_filename:
|
|
66
|
+
repository_filename: str | None = typer.Option(
|
|
68
67
|
None,
|
|
69
68
|
"--repository-filename",
|
|
70
69
|
help="Custom filename for repository (default: {domain}_repository.py)",
|
|
71
70
|
),
|
|
72
71
|
) -> None:
|
|
73
72
|
"""Generate SQLAlchemy models, Pydantic schemas, and repository code from templates.
|
|
74
|
-
|
|
73
|
+
|
|
75
74
|
The scaffold command generates production-ready persistence layer code that works
|
|
76
75
|
seamlessly with svc-infra's add_sql_resources() for automatic CRUD APIs.
|
|
77
|
-
|
|
76
|
+
|
|
78
77
|
Examples:
|
|
79
78
|
# Basic scaffold (models + schemas + repository)
|
|
80
79
|
fin-infra scaffold budgets --dest-dir app/models/
|
|
81
|
-
|
|
80
|
+
|
|
82
81
|
# Financial goals tracking
|
|
83
82
|
fin-infra scaffold goals --dest-dir app/models/goals/
|
|
84
|
-
|
|
83
|
+
|
|
85
84
|
# With multi-tenancy and soft deletes
|
|
86
85
|
fin-infra scaffold budgets --dest-dir app/models/ \
|
|
87
86
|
--include-tenant --include-soft-delete
|
|
88
|
-
|
|
87
|
+
|
|
89
88
|
# Without repository (use svc-infra SqlRepository directly)
|
|
90
89
|
fin-infra scaffold goals --dest-dir app/models/ \\
|
|
91
90
|
--no-with-repository
|
|
92
|
-
|
|
91
|
+
|
|
93
92
|
# Custom filenames
|
|
94
93
|
fin-infra scaffold budgets --dest-dir app/models/ \\
|
|
95
94
|
--models-filename custom_budget.py \\
|
|
96
95
|
--schemas-filename custom_schemas.py
|
|
97
|
-
|
|
96
|
+
|
|
98
97
|
After scaffolding, integrate with svc-infra:
|
|
99
98
|
1. Run migrations: svc-infra revision -m "add budgets" --autogenerate
|
|
100
99
|
2. Apply: svc-infra upgrade head
|
|
@@ -108,7 +107,7 @@ def cmd_scaffold(
|
|
|
108
107
|
err=True,
|
|
109
108
|
)
|
|
110
109
|
raise typer.Exit(1)
|
|
111
|
-
|
|
110
|
+
|
|
112
111
|
# Import scaffold function based on domain
|
|
113
112
|
if domain == "budgets":
|
|
114
113
|
from fin_infra.scaffold.budgets import scaffold_budgets_core
|
fin_infra/clients/__init__.py
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
"""DEPRECATED: Use fin_infra.providers instead.
|
|
2
|
+
|
|
3
|
+
This module is deprecated and will be removed in a future version.
|
|
4
|
+
All ABCs have been consolidated into fin_infra.providers.base.
|
|
5
|
+
|
|
6
|
+
Migration:
|
|
7
|
+
# Old (deprecated)
|
|
8
|
+
from fin_infra.clients import BankingClient, MarketDataClient
|
|
9
|
+
|
|
10
|
+
# New
|
|
11
|
+
from fin_infra.providers.base import BankingProvider, MarketDataProvider
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
from .base import BankingClient, CreditClient, MarketDataClient
|
|
17
|
+
|
|
18
|
+
warnings.warn(
|
|
19
|
+
"fin_infra.clients is deprecated. Use fin_infra.providers instead. "
|
|
20
|
+
"This module will be removed in a future version.",
|
|
21
|
+
DeprecationWarning,
|
|
22
|
+
stacklevel=2,
|
|
23
|
+
)
|
|
2
24
|
|
|
3
25
|
__all__ = ["BankingClient", "MarketDataClient", "CreditClient"]
|
fin_infra/clients/base.py
CHANGED
fin_infra/clients/plaid.py
CHANGED
fin_infra/compliance/__init__.py
CHANGED
|
@@ -20,8 +20,9 @@ Example:
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import logging
|
|
23
|
+
from collections.abc import Callable
|
|
23
24
|
from datetime import datetime
|
|
24
|
-
from typing import
|
|
25
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
27
28
|
from fastapi import FastAPI, Request, Response
|
|
@@ -32,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def log_compliance_event(
|
|
35
|
-
app:
|
|
36
|
+
app: FastAPI,
|
|
36
37
|
event: str,
|
|
37
38
|
context: dict[str, Any] | None = None,
|
|
38
39
|
) -> None:
|
|
@@ -62,7 +63,7 @@ def log_compliance_event(
|
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
def add_compliance_tracking(
|
|
65
|
-
app:
|
|
66
|
+
app: FastAPI,
|
|
66
67
|
*,
|
|
67
68
|
track_banking: bool = True,
|
|
68
69
|
track_credit: bool = True,
|
|
@@ -111,14 +112,14 @@ def add_compliance_tracking(
|
|
|
111
112
|
"""
|
|
112
113
|
|
|
113
114
|
@app.middleware("http")
|
|
114
|
-
async def compliance_tracking_middleware(request:
|
|
115
|
+
async def compliance_tracking_middleware(request: Request, call_next: Callable) -> Response:
|
|
115
116
|
"""Middleware to track compliance events for financial endpoints."""
|
|
116
117
|
path = request.url.path
|
|
117
118
|
method = request.method
|
|
118
119
|
|
|
119
120
|
# Track only GET requests (data access)
|
|
120
121
|
if method != "GET":
|
|
121
|
-
return await call_next(request)
|
|
122
|
+
return cast("Response", await call_next(request))
|
|
122
123
|
|
|
123
124
|
# Determine if path is a compliance-tracked endpoint
|
|
124
125
|
event = None
|
|
@@ -148,7 +149,7 @@ def add_compliance_tracking(
|
|
|
148
149
|
if on_event:
|
|
149
150
|
on_event(event, context)
|
|
150
151
|
|
|
151
|
-
return response
|
|
152
|
+
return cast("Response", response)
|
|
152
153
|
|
|
153
154
|
logger.info(
|
|
154
155
|
"Compliance tracking enabled",
|
fin_infra/credit/add.py
CHANGED
|
@@ -23,16 +23,16 @@ Example:
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import logging
|
|
26
|
+
from typing import cast
|
|
26
27
|
|
|
27
|
-
from fastapi import
|
|
28
|
-
|
|
29
|
-
from svc_infra.api.fastapi.dual.protected import user_router, RequireUser
|
|
28
|
+
from fastapi import Depends, FastAPI, HTTPException, status
|
|
30
29
|
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
30
|
+
from svc_infra.api.fastapi.dual.protected import RequireUser, user_router
|
|
31
31
|
from svc_infra.cache import resource
|
|
32
32
|
from svc_infra.webhooks import add_webhooks
|
|
33
33
|
|
|
34
|
+
from fin_infra.models.credit import CreditReport, CreditScore
|
|
34
35
|
from fin_infra.providers.base import CreditProvider
|
|
35
|
-
from fin_infra.models.credit import CreditScore, CreditReport
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger(__name__)
|
|
38
38
|
|
|
@@ -155,8 +155,8 @@ def add_credit(
|
|
|
155
155
|
if enable_webhooks and hasattr(app.state, "webhooks_outbox"):
|
|
156
156
|
try:
|
|
157
157
|
# Get webhook service from app state
|
|
158
|
-
from svc_infra.webhooks.service import WebhookService
|
|
159
158
|
from svc_infra.db.outbox import OutboxStore
|
|
159
|
+
from svc_infra.webhooks.service import WebhookService
|
|
160
160
|
|
|
161
161
|
outbox: OutboxStore = app.state.webhooks_outbox
|
|
162
162
|
subs = app.state.webhooks_subscriptions
|
|
@@ -175,7 +175,7 @@ def add_credit(
|
|
|
175
175
|
# Don't fail request if webhook publishing fails
|
|
176
176
|
logger.warning(f"Failed to publish credit.score_changed webhook: {e}")
|
|
177
177
|
|
|
178
|
-
return score
|
|
178
|
+
return cast("CreditScore", score)
|
|
179
179
|
|
|
180
180
|
@router.post("/report", response_model=CreditReport)
|
|
181
181
|
@credit_resource.cache_read(ttl=cache_ttl, suffix="report")
|
|
@@ -219,7 +219,7 @@ def add_credit(
|
|
|
219
219
|
detail="Credit bureau service unavailable",
|
|
220
220
|
)
|
|
221
221
|
|
|
222
|
-
return report
|
|
222
|
+
return cast("CreditReport", report)
|
|
223
223
|
|
|
224
224
|
# Mount router with dual routes (with/without trailing slash)
|
|
225
225
|
app.include_router(router, include_in_schema=True)
|
|
@@ -24,6 +24,7 @@ Example:
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import base64
|
|
27
|
+
from typing import cast
|
|
27
28
|
|
|
28
29
|
import httpx
|
|
29
30
|
from svc_infra.cache import cache_read
|
|
@@ -85,7 +86,7 @@ class ExperianAuthManager:
|
|
|
85
86
|
>>> headers = {"Authorization": f"Bearer {token}"}
|
|
86
87
|
"""
|
|
87
88
|
# Call the cached implementation with client_id for cache key
|
|
88
|
-
return await self._get_token_cached(client_id=self.client_id)
|
|
89
|
+
return cast("str", await self._get_token_cached(client_id=self.client_id))
|
|
89
90
|
|
|
90
91
|
@cache_read(
|
|
91
92
|
key="oauth_token:experian:{client_id}", # Use client_id for uniqueness
|
|
@@ -140,7 +141,7 @@ class ExperianAuthManager:
|
|
|
140
141
|
|
|
141
142
|
# Parse and return token
|
|
142
143
|
data = response.json()
|
|
143
|
-
return data["access_token"]
|
|
144
|
+
return cast("str", data["access_token"])
|
|
144
145
|
|
|
145
146
|
async def invalidate(self) -> None:
|
|
146
147
|
"""Invalidate cached token for THIS client (force refresh on next get_token call).
|
|
@@ -14,7 +14,7 @@ Example:
|
|
|
14
14
|
>>> data = await client.get_credit_score("user123")
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
from typing import Any
|
|
17
|
+
from typing import Any, cast
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
from tenacity import (
|
|
@@ -155,7 +155,7 @@ class ExperianClient:
|
|
|
155
155
|
**kwargs,
|
|
156
156
|
)
|
|
157
157
|
response.raise_for_status()
|
|
158
|
-
return response.json()
|
|
158
|
+
return cast("dict[str, Any]", response.json())
|
|
159
159
|
|
|
160
160
|
except httpx.HTTPStatusError as e:
|
|
161
161
|
# Parse error response
|