fin-infra 0.1.69__py3-none-any.whl → 0.4.0__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 +24 -24
- fin_infra/analytics/cash_flow.py +3 -3
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/models.py +5 -5
- fin_infra/analytics/portfolio.py +18 -18
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/spending.py +4 -5
- fin_infra/banking/__init__.py +27 -28
- fin_infra/banking/history.py +12 -13
- fin_infra/banking/utils.py +27 -26
- fin_infra/brokerage/__init__.py +29 -31
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +4 -4
- fin_infra/budgets/ease.py +1 -2
- fin_infra/budgets/models.py +1 -2
- fin_infra/budgets/templates.py +4 -4
- fin_infra/budgets/tracker.py +4 -4
- fin_infra/cashflows/__init__.py +3 -3
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/ease.py +3 -3
- fin_infra/categorization/engine.py +18 -15
- fin_infra/categorization/llm_layer.py +13 -10
- fin_infra/categorization/models.py +3 -4
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +6 -6
- fin_infra/chat/planning.py +1 -2
- fin_infra/cli/cmds/scaffold_cmds.py +16 -17
- 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 +5 -4
- fin_infra/credit/add.py +6 -7
- fin_infra/credit/experian/auth.py +2 -2
- fin_infra/credit/experian/client.py +1 -1
- fin_infra/credit/experian/parser.py +5 -5
- fin_infra/credit/experian/provider.py +4 -4
- fin_infra/crypto/__init__.py +9 -11
- fin_infra/crypto/insights.py +4 -3
- fin_infra/documents/add.py +6 -8
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +14 -14
- fin_infra/documents/models.py +5 -6
- fin_infra/documents/ocr.py +7 -7
- fin_infra/documents/storage.py +21 -13
- fin_infra/exceptions.py +0 -1
- 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 +5 -6
- fin_infra/goals/milestones.py +7 -8
- fin_infra/goals/models.py +9 -13
- fin_infra/insights/__init__.py +6 -3
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +3 -3
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/models.py +27 -29
- fin_infra/investments/providers/base.py +12 -13
- fin_infra/investments/providers/plaid.py +52 -26
- fin_infra/investments/providers/snaptrade.py +19 -19
- fin_infra/investments/scaffold_templates/README.md +17 -17
- fin_infra/markets/__init__.py +7 -5
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +4 -5
- 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 -5
- fin_infra/net_worth/__init__.py +8 -1
- fin_infra/net_worth/aggregator.py +5 -3
- fin_infra/net_worth/calculator.py +1 -1
- fin_infra/net_worth/insights.py +7 -8
- fin_infra/normalization/__init__.py +4 -4
- fin_infra/normalization/currency_converter.py +7 -8
- fin_infra/normalization/models.py +9 -10
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/normalization/providers/static_mappings.py +1 -1
- fin_infra/normalization/symbol_resolver.py +3 -4
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +5 -5
- fin_infra/providers/banking/teller_client.py +7 -6
- fin_infra/providers/base.py +27 -2
- fin_infra/providers/brokerage/alpaca.py +4 -4
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +19 -3
- 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 +5 -5
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +6 -5
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +10 -10
- fin_infra/recurring/ease.py +6 -8
- fin_infra/recurring/insights.py +25 -24
- fin_infra/recurring/normalizer.py +7 -7
- fin_infra/recurring/normalizers.py +31 -30
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +9 -9
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/add.py +1 -2
- fin_infra/security/audit.py +6 -7
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +16 -16
- fin_infra/security/token_store.py +2 -3
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +5 -4
- fin_infra/tax/tlh.py +10 -10
- fin_infra/utils/__init__.py +15 -1
- fin_infra/utils/deprecation.py +161 -0
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
- fin_infra-0.4.0.dist-info/RECORD +181 -0
- fin_infra-0.1.69.dist-info/RECORD +0 -180
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
fin_infra/documents/ocr.py
CHANGED
|
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import re
|
|
27
27
|
from datetime import datetime
|
|
28
|
-
from typing import TYPE_CHECKING
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
29
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
31
|
from svc_infra.storage.base import StorageBackend
|
|
@@ -33,15 +33,15 @@ if TYPE_CHECKING:
|
|
|
33
33
|
from .models import OCRResult
|
|
34
34
|
|
|
35
35
|
# In-memory OCR cache (production: use svc-infra cache)
|
|
36
|
-
_ocr_cache:
|
|
36
|
+
_ocr_cache: dict[str, OCRResult] = {}
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
async def extract_text(
|
|
40
|
-
storage:
|
|
40
|
+
storage: StorageBackend,
|
|
41
41
|
document_id: str,
|
|
42
42
|
provider: str = "tesseract",
|
|
43
43
|
force_refresh: bool = False,
|
|
44
|
-
) ->
|
|
44
|
+
) -> OCRResult:
|
|
45
45
|
"""
|
|
46
46
|
Extract text from a document using OCR (uses svc-infra storage).
|
|
47
47
|
|
|
@@ -106,7 +106,7 @@ async def extract_text(
|
|
|
106
106
|
|
|
107
107
|
def _extract_with_tesseract(
|
|
108
108
|
file_content: bytes, filename: str, metadata: dict, document_id: str
|
|
109
|
-
) ->
|
|
109
|
+
) -> OCRResult:
|
|
110
110
|
"""
|
|
111
111
|
Extract text using Tesseract OCR (simulated).
|
|
112
112
|
|
|
@@ -158,7 +158,7 @@ def _extract_with_tesseract(
|
|
|
158
158
|
|
|
159
159
|
def _extract_with_textract(
|
|
160
160
|
file_content: bytes, filename: str, metadata: dict, document_id: str
|
|
161
|
-
) ->
|
|
161
|
+
) -> OCRResult:
|
|
162
162
|
"""
|
|
163
163
|
Extract text using AWS Textract (simulated).
|
|
164
164
|
|
|
@@ -207,7 +207,7 @@ def _extract_with_textract(
|
|
|
207
207
|
)
|
|
208
208
|
|
|
209
209
|
|
|
210
|
-
def _parse_tax_form(text: str, form_type:
|
|
210
|
+
def _parse_tax_form(text: str, form_type: str | None = None) -> dict[str, str]:
|
|
211
211
|
"""
|
|
212
212
|
Parse tax form text into structured fields.
|
|
213
213
|
|
fin_infra/documents/storage.py
CHANGED
|
@@ -36,14 +36,22 @@ Quick Start:
|
|
|
36
36
|
|
|
37
37
|
from __future__ import annotations
|
|
38
38
|
|
|
39
|
-
from typing import TYPE_CHECKING
|
|
39
|
+
from typing import TYPE_CHECKING
|
|
40
40
|
|
|
41
41
|
try:
|
|
42
42
|
from svc_infra.documents import (
|
|
43
43
|
delete_document as base_delete_document,
|
|
44
|
+
)
|
|
45
|
+
from svc_infra.documents import (
|
|
44
46
|
download_document as base_download_document,
|
|
47
|
+
)
|
|
48
|
+
from svc_infra.documents import (
|
|
45
49
|
get_document as base_get_document,
|
|
50
|
+
)
|
|
51
|
+
from svc_infra.documents import (
|
|
46
52
|
list_documents as base_list_documents,
|
|
53
|
+
)
|
|
54
|
+
from svc_infra.documents import (
|
|
47
55
|
upload_document as base_upload_document,
|
|
48
56
|
)
|
|
49
57
|
|
|
@@ -64,15 +72,15 @@ if TYPE_CHECKING:
|
|
|
64
72
|
|
|
65
73
|
|
|
66
74
|
async def upload_document(
|
|
67
|
-
storage:
|
|
75
|
+
storage: StorageBackend,
|
|
68
76
|
user_id: str,
|
|
69
77
|
file: bytes,
|
|
70
|
-
document_type:
|
|
78
|
+
document_type: DocumentType,
|
|
71
79
|
filename: str,
|
|
72
|
-
metadata:
|
|
73
|
-
tax_year:
|
|
74
|
-
form_type:
|
|
75
|
-
) ->
|
|
80
|
+
metadata: dict | None = None,
|
|
81
|
+
tax_year: int | None = None,
|
|
82
|
+
form_type: str | None = None,
|
|
83
|
+
) -> FinancialDocument:
|
|
76
84
|
"""
|
|
77
85
|
Upload a financial document (delegates to svc-infra, adds financial fields).
|
|
78
86
|
|
|
@@ -140,7 +148,7 @@ async def upload_document(
|
|
|
140
148
|
return financial_doc
|
|
141
149
|
|
|
142
150
|
|
|
143
|
-
def get_document(document_id: str) ->
|
|
151
|
+
def get_document(document_id: str) -> FinancialDocument | None:
|
|
144
152
|
"""
|
|
145
153
|
Get financial document metadata by ID (delegates to svc-infra).
|
|
146
154
|
|
|
@@ -187,7 +195,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
|
|
|
187
195
|
return financial_doc
|
|
188
196
|
|
|
189
197
|
|
|
190
|
-
async def download_document(storage:
|
|
198
|
+
async def download_document(storage: StorageBackend, document_id: str) -> bytes:
|
|
191
199
|
"""
|
|
192
200
|
Download a financial document by ID (delegates to svc-infra).
|
|
193
201
|
|
|
@@ -213,7 +221,7 @@ async def download_document(storage: "StorageBackend", document_id: str) -> byte
|
|
|
213
221
|
return await base_download_document(storage=storage, document_id=document_id)
|
|
214
222
|
|
|
215
223
|
|
|
216
|
-
async def delete_document(storage:
|
|
224
|
+
async def delete_document(storage: StorageBackend, document_id: str) -> bool:
|
|
217
225
|
"""
|
|
218
226
|
Delete a financial document and its metadata (delegates to svc-infra).
|
|
219
227
|
|
|
@@ -238,11 +246,11 @@ async def delete_document(storage: "StorageBackend", document_id: str) -> bool:
|
|
|
238
246
|
|
|
239
247
|
def list_documents(
|
|
240
248
|
user_id: str,
|
|
241
|
-
document_type:
|
|
242
|
-
tax_year:
|
|
249
|
+
document_type: DocumentType | None = None,
|
|
250
|
+
tax_year: int | None = None,
|
|
243
251
|
limit: int = 100,
|
|
244
252
|
offset: int = 0,
|
|
245
|
-
) ->
|
|
253
|
+
) -> list[FinancialDocument]:
|
|
246
254
|
"""
|
|
247
255
|
List user's financial documents with optional filters (delegates to svc-infra).
|
|
248
256
|
|
fin_infra/exceptions.py
CHANGED
|
@@ -23,7 +23,6 @@ from __future__ import annotations
|
|
|
23
23
|
import logging
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
|
-
|
|
27
26
|
# =============================================================================
|
|
28
27
|
# Logging Helper
|
|
29
28
|
# =============================================================================
|
fin_infra/goals/__init__.py
CHANGED
|
@@ -5,6 +5,14 @@ Provides comprehensive goal management with milestone tracking,
|
|
|
5
5
|
funding allocation, and progress monitoring.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from fin_infra.goals.add import add_goals
|
|
9
|
+
from fin_infra.goals.funding import (
|
|
10
|
+
get_account_allocations,
|
|
11
|
+
get_goal_funding_sources,
|
|
12
|
+
link_account_to_goal,
|
|
13
|
+
remove_account_from_goal,
|
|
14
|
+
update_account_allocation,
|
|
15
|
+
)
|
|
8
16
|
from fin_infra.goals.management import (
|
|
9
17
|
FinancialGoalTracker,
|
|
10
18
|
GoalProgressReport,
|
|
@@ -28,14 +36,6 @@ from fin_infra.goals.milestones import (
|
|
|
28
36
|
get_next_milestone,
|
|
29
37
|
trigger_milestone_notification,
|
|
30
38
|
)
|
|
31
|
-
from fin_infra.goals.funding import (
|
|
32
|
-
link_account_to_goal,
|
|
33
|
-
get_goal_funding_sources,
|
|
34
|
-
get_account_allocations,
|
|
35
|
-
update_account_allocation,
|
|
36
|
-
remove_account_from_goal,
|
|
37
|
-
)
|
|
38
|
-
from fin_infra.goals.add import add_goals
|
|
39
39
|
from fin_infra.goals.models import (
|
|
40
40
|
FundingSource,
|
|
41
41
|
Goal,
|
fin_infra/goals/add.py
CHANGED
|
@@ -29,30 +29,30 @@ add_goals(app)
|
|
|
29
29
|
|
|
30
30
|
import logging
|
|
31
31
|
from datetime import datetime
|
|
32
|
-
from typing import Any,
|
|
32
|
+
from typing import Any, cast
|
|
33
33
|
|
|
34
|
-
from fastapi import FastAPI, HTTPException,
|
|
34
|
+
from fastapi import Body, FastAPI, HTTPException, Query, status
|
|
35
35
|
from pydantic import BaseModel, Field
|
|
36
36
|
|
|
37
|
+
from fin_infra.goals.funding import (
|
|
38
|
+
get_goal_funding_sources,
|
|
39
|
+
link_account_to_goal,
|
|
40
|
+
remove_account_from_goal,
|
|
41
|
+
update_account_allocation,
|
|
42
|
+
)
|
|
37
43
|
from fin_infra.goals.management import (
|
|
38
44
|
create_goal,
|
|
39
|
-
list_goals,
|
|
40
|
-
get_goal,
|
|
41
|
-
update_goal,
|
|
42
45
|
delete_goal,
|
|
46
|
+
get_goal,
|
|
43
47
|
get_goal_progress,
|
|
48
|
+
list_goals,
|
|
49
|
+
update_goal,
|
|
44
50
|
)
|
|
45
51
|
from fin_infra.goals.milestones import (
|
|
46
52
|
add_milestone,
|
|
47
53
|
check_milestones,
|
|
48
54
|
get_milestone_progress,
|
|
49
55
|
)
|
|
50
|
-
from fin_infra.goals.funding import (
|
|
51
|
-
link_account_to_goal,
|
|
52
|
-
get_goal_funding_sources,
|
|
53
|
-
update_account_allocation,
|
|
54
|
-
remove_account_from_goal,
|
|
55
|
-
)
|
|
56
56
|
from fin_infra.goals.models import GoalStatus
|
|
57
57
|
|
|
58
58
|
logger = logging.getLogger(__name__)
|
|
@@ -63,7 +63,7 @@ logger = logging.getLogger(__name__)
|
|
|
63
63
|
# ============================================================================
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def parse_iso_date(date_str:
|
|
66
|
+
def parse_iso_date(date_str: str | None) -> datetime | None:
|
|
67
67
|
"""Parse ISO date string to datetime object."""
|
|
68
68
|
if date_str is None:
|
|
69
69
|
return None
|
|
@@ -85,24 +85,24 @@ class CreateGoalRequest(BaseModel):
|
|
|
85
85
|
name: str = Field(..., min_length=1, max_length=200, description="Goal name")
|
|
86
86
|
goal_type: str = Field(..., description="Goal type (savings, debt, investment, etc.)")
|
|
87
87
|
target_amount: float = Field(..., gt=0, description="Target amount")
|
|
88
|
-
deadline:
|
|
89
|
-
description:
|
|
90
|
-
current_amount:
|
|
91
|
-
auto_contribute:
|
|
92
|
-
tags:
|
|
88
|
+
deadline: str | None = Field(None, description="Deadline (ISO date)")
|
|
89
|
+
description: str | None = Field(None, description="Goal description")
|
|
90
|
+
current_amount: float | None = Field(0.0, ge=0, description="Current amount")
|
|
91
|
+
auto_contribute: bool | None = Field(False, description="Auto-contribute enabled")
|
|
92
|
+
tags: list[str] | None = Field(None, description="Goal tags")
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
class UpdateGoalRequest(BaseModel):
|
|
96
96
|
"""Request body for updating a goal."""
|
|
97
97
|
|
|
98
|
-
name:
|
|
99
|
-
target_amount:
|
|
100
|
-
deadline:
|
|
101
|
-
description:
|
|
102
|
-
current_amount:
|
|
103
|
-
status:
|
|
104
|
-
auto_contribute:
|
|
105
|
-
tags:
|
|
98
|
+
name: str | None = Field(None, min_length=1, max_length=200)
|
|
99
|
+
target_amount: float | None = Field(None, gt=0)
|
|
100
|
+
deadline: str | None = None
|
|
101
|
+
description: str | None = None
|
|
102
|
+
current_amount: float | None = Field(None, ge=0)
|
|
103
|
+
status: GoalStatus | None = None
|
|
104
|
+
auto_contribute: bool | None = None
|
|
105
|
+
tags: list[str] | None = None
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
class AddMilestoneRequest(BaseModel):
|
|
@@ -110,7 +110,7 @@ class AddMilestoneRequest(BaseModel):
|
|
|
110
110
|
|
|
111
111
|
amount: float = Field(..., gt=0, description="Milestone amount")
|
|
112
112
|
description: str = Field(..., min_length=1, description="Milestone description")
|
|
113
|
-
target_date:
|
|
113
|
+
target_date: str | None = Field(None, description="Target date (ISO date)")
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
class LinkAccountRequest(BaseModel):
|
|
@@ -238,14 +238,14 @@ def add_goals(
|
|
|
238
238
|
except ValueError as e:
|
|
239
239
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
240
240
|
|
|
241
|
-
@router.get("", response_model=
|
|
241
|
+
@router.get("", response_model=list[dict])
|
|
242
242
|
async def list_goals_endpoint(
|
|
243
|
-
user_id:
|
|
243
|
+
user_id: str | None = Query(
|
|
244
244
|
None, description="User identifier (optional, returns all if not provided)"
|
|
245
245
|
),
|
|
246
|
-
goal_type:
|
|
247
|
-
status_filter:
|
|
248
|
-
) ->
|
|
246
|
+
goal_type: str | None = Query(None, description="Filter by goal type"),
|
|
247
|
+
status_filter: str | None = Query(None, alias="status", description="Filter by status"),
|
|
248
|
+
) -> list[dict]:
|
|
249
249
|
"""
|
|
250
250
|
List all goals for a user with optional filters.
|
|
251
251
|
|
|
@@ -442,8 +442,8 @@ def add_goals(
|
|
|
442
442
|
except ValueError as e:
|
|
443
443
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
444
444
|
|
|
445
|
-
@router.get("/{goal_id}/milestones", response_model=
|
|
446
|
-
async def list_milestones_endpoint(goal_id: str) ->
|
|
445
|
+
@router.get("/{goal_id}/milestones", response_model=list[dict])
|
|
446
|
+
async def list_milestones_endpoint(goal_id: str) -> list[dict]:
|
|
447
447
|
"""
|
|
448
448
|
List all milestones for a goal.
|
|
449
449
|
|
|
@@ -469,7 +469,7 @@ def add_goals(
|
|
|
469
469
|
# Get all milestones from the goal (check_milestones only returns newly reached ones)
|
|
470
470
|
goal = get_goal(goal_id)
|
|
471
471
|
milestones = goal.get("milestones", [])
|
|
472
|
-
return cast(list[dict[Any, Any]], milestones)
|
|
472
|
+
return cast("list[dict[Any, Any]]", milestones)
|
|
473
473
|
except KeyError:
|
|
474
474
|
raise HTTPException(
|
|
475
475
|
status_code=status.HTTP_404_NOT_FOUND, detail=f"Goal {goal_id} not found"
|
|
@@ -540,8 +540,8 @@ def add_goals(
|
|
|
540
540
|
except ValueError as e:
|
|
541
541
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
542
542
|
|
|
543
|
-
@router.get("/{goal_id}/funding", response_model=
|
|
544
|
-
async def list_funding_sources_endpoint(goal_id: str) ->
|
|
543
|
+
@router.get("/{goal_id}/funding", response_model=list[dict])
|
|
544
|
+
async def list_funding_sources_endpoint(goal_id: str) -> list[dict]:
|
|
545
545
|
"""
|
|
546
546
|
List all funding sources for a goal.
|
|
547
547
|
|
fin_infra/goals/funding.py
CHANGED
|
@@ -22,14 +22,12 @@ Example:
|
|
|
22
22
|
>>> # Raises ValueError if total allocation > 100%
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
from typing import Dict, List
|
|
26
|
-
|
|
27
|
-
from fin_infra.goals.models import FundingSource
|
|
28
25
|
from fin_infra.goals.management import get_goal
|
|
26
|
+
from fin_infra.goals.models import FundingSource
|
|
29
27
|
|
|
30
28
|
# In-memory storage for funding allocations
|
|
31
29
|
# Structure: {account_id: {goal_id: allocation_percent}}
|
|
32
|
-
_FUNDING_STORE:
|
|
30
|
+
_FUNDING_STORE: dict[str, dict[str, float]] = {}
|
|
33
31
|
|
|
34
32
|
|
|
35
33
|
def link_account_to_goal(
|
|
@@ -108,7 +106,7 @@ def link_account_to_goal(
|
|
|
108
106
|
)
|
|
109
107
|
|
|
110
108
|
|
|
111
|
-
def get_goal_funding_sources(goal_id: str) ->
|
|
109
|
+
def get_goal_funding_sources(goal_id: str) -> list[FundingSource]:
|
|
112
110
|
"""
|
|
113
111
|
Get all accounts funding a specific goal.
|
|
114
112
|
|
|
@@ -154,7 +152,7 @@ def get_goal_funding_sources(goal_id: str) -> List[FundingSource]:
|
|
|
154
152
|
return funding_sources
|
|
155
153
|
|
|
156
154
|
|
|
157
|
-
def get_account_allocations(account_id: str) ->
|
|
155
|
+
def get_account_allocations(account_id: str) -> dict[str, float]:
|
|
158
156
|
"""
|
|
159
157
|
Get all goal allocations for a specific account.
|
|
160
158
|
|
fin_infra/goals/management.py
CHANGED
|
@@ -45,7 +45,6 @@ from typing import Any, cast
|
|
|
45
45
|
|
|
46
46
|
from pydantic import BaseModel, Field
|
|
47
47
|
|
|
48
|
-
|
|
49
48
|
# ============================================================================
|
|
50
49
|
# Pydantic Schemas (Structured Output)
|
|
51
50
|
# ============================================================================
|
|
@@ -183,7 +182,7 @@ Your response: {
|
|
|
183
182
|
"confidence": 0.94
|
|
184
183
|
}
|
|
185
184
|
|
|
186
|
-
|
|
185
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
187
186
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
188
187
|
|
|
189
188
|
GOAL_PROGRESS_SYSTEM_PROMPT = """You are a financial advisor reviewing goal progress.
|
|
@@ -238,7 +237,7 @@ Your response: {
|
|
|
238
237
|
"projected_completion_date": "2029-06-01",
|
|
239
238
|
"variance_from_target_days": -365,
|
|
240
239
|
"course_corrections": [
|
|
241
|
-
"
|
|
240
|
+
"[!] 12 months behind! Current $1,000/month payment needs to increase to $1,500/month",
|
|
242
241
|
"Emergency: reduce expenses by $500/month (cancel subscriptions, cut entertainment)",
|
|
243
242
|
"Contact debt counselor for consolidation or negotiation options",
|
|
244
243
|
"Consider side income: gig work, selling unused items ($500/month target)",
|
|
@@ -247,7 +246,7 @@ Your response: {
|
|
|
247
246
|
"confidence": 0.95
|
|
248
247
|
}
|
|
249
248
|
|
|
250
|
-
|
|
249
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
251
250
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
252
251
|
|
|
253
252
|
|
|
@@ -839,7 +838,7 @@ def get_goal(goal_id: str) -> dict[str, Any]:
|
|
|
839
838
|
if goal_id not in _GOALS_STORE:
|
|
840
839
|
raise KeyError(f"Goal not found: {goal_id}")
|
|
841
840
|
|
|
842
|
-
return cast(dict[str, Any], _GOALS_STORE[goal_id])
|
|
841
|
+
return cast("dict[str, Any]", _GOALS_STORE[goal_id])
|
|
843
842
|
|
|
844
843
|
|
|
845
844
|
def update_goal(
|
|
@@ -885,7 +884,7 @@ def update_goal(
|
|
|
885
884
|
|
|
886
885
|
Goal(**goal) # Will raise ValidationError if invalid
|
|
887
886
|
|
|
888
|
-
return cast(dict[str, Any], goal)
|
|
887
|
+
return cast("dict[str, Any]", goal)
|
|
889
888
|
|
|
890
889
|
|
|
891
890
|
def delete_goal(goal_id: str) -> None:
|
fin_infra/goals/milestones.py
CHANGED
|
@@ -22,7 +22,7 @@ Example:
|
|
|
22
22
|
# Check which milestones have been reached
|
|
23
23
|
reached = check_milestones("goal_123")
|
|
24
24
|
for m in reached:
|
|
25
|
-
print(f"
|
|
25
|
+
print(f" Milestone reached: {m['description']}")
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from datetime import datetime
|
|
@@ -31,7 +31,6 @@ from typing import Any, cast
|
|
|
31
31
|
from fin_infra.goals.management import get_goal, update_goal
|
|
32
32
|
from fin_infra.goals.models import Milestone
|
|
33
33
|
|
|
34
|
-
|
|
35
34
|
# ============================================================================
|
|
36
35
|
# Milestone Management
|
|
37
36
|
# ============================================================================
|
|
@@ -125,7 +124,7 @@ def check_milestones(goal_id: str) -> list[dict[str, Any]]:
|
|
|
125
124
|
|
|
126
125
|
reached = check_milestones("goal_123")
|
|
127
126
|
if reached:
|
|
128
|
-
print(f"
|
|
127
|
+
print(f" {len(reached)} milestones reached!")
|
|
129
128
|
for m in reached:
|
|
130
129
|
print(f" - {m['description']}: ${m['amount']:,.0f}")
|
|
131
130
|
|
|
@@ -185,17 +184,17 @@ def get_celebration_message(milestone: dict[str, Any]) -> str:
|
|
|
185
184
|
|
|
186
185
|
Example:
|
|
187
186
|
message = get_celebration_message(milestone)
|
|
188
|
-
# "
|
|
187
|
+
# " Milestone reached! You've hit $25,000 - 25% to target!"
|
|
189
188
|
"""
|
|
190
189
|
amount = milestone["amount"]
|
|
191
190
|
description = milestone["description"]
|
|
192
191
|
|
|
193
192
|
messages = [
|
|
194
|
-
f"
|
|
193
|
+
f" Milestone reached! You've hit ${amount:,.0f} - {description}!",
|
|
195
194
|
f"🎊 Congratulations! ${amount:,.0f} milestone achieved - {description}",
|
|
196
195
|
f"🌟 Great progress! You reached ${amount:,.0f} - {description}",
|
|
197
|
-
f"
|
|
198
|
-
f"
|
|
196
|
+
f" Keep going! ${amount:,.0f} milestone completed - {description}",
|
|
197
|
+
f" Amazing! You hit ${amount:,.0f} - {description}",
|
|
199
198
|
]
|
|
200
199
|
|
|
201
200
|
# Use amount to pick consistent message for same milestone
|
|
@@ -229,7 +228,7 @@ def get_next_milestone(goal_id: str) -> dict[str, Any] | None:
|
|
|
229
228
|
# Find first unreached milestone (sorted by amount)
|
|
230
229
|
for milestone in milestones:
|
|
231
230
|
if not milestone.get("reached", False):
|
|
232
|
-
return cast(dict[str, Any], milestone)
|
|
231
|
+
return cast("dict[str, Any]", milestone)
|
|
233
232
|
|
|
234
233
|
return None
|
|
235
234
|
|
fin_infra/goals/models.py
CHANGED
|
@@ -11,11 +11,9 @@ Provides comprehensive data models for:
|
|
|
11
11
|
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
from enum import Enum
|
|
14
|
-
from typing import Optional
|
|
15
14
|
|
|
16
15
|
from pydantic import BaseModel, Field, field_validator
|
|
17
16
|
|
|
18
|
-
|
|
19
17
|
# ============================================================================
|
|
20
18
|
# Enums
|
|
21
19
|
# ============================================================================
|
|
@@ -69,14 +67,14 @@ class Milestone(BaseModel):
|
|
|
69
67
|
"""
|
|
70
68
|
|
|
71
69
|
amount: float = Field(..., description="Milestone target amount", gt=0)
|
|
72
|
-
target_date:
|
|
70
|
+
target_date: datetime | None = Field(
|
|
73
71
|
None, description="Target date to reach milestone (optional)"
|
|
74
72
|
)
|
|
75
73
|
description: str = Field(
|
|
76
74
|
..., description="Milestone description (e.g., '25% to emergency fund')", max_length=200
|
|
77
75
|
)
|
|
78
76
|
reached: bool = Field(default=False, description="Whether milestone has been reached")
|
|
79
|
-
reached_date:
|
|
77
|
+
reached_date: datetime | None = Field(
|
|
80
78
|
None, description="Date milestone was reached (if reached=True)"
|
|
81
79
|
)
|
|
82
80
|
|
|
@@ -95,8 +93,8 @@ class FundingSource(BaseModel):
|
|
|
95
93
|
|
|
96
94
|
Supports split allocation:
|
|
97
95
|
- Multiple accounts can fund one goal (e.g., savings + checking)
|
|
98
|
-
- One account can fund multiple goals (e.g., savings
|
|
99
|
-
- Allocation percentages must sum to
|
|
96
|
+
- One account can fund multiple goals (e.g., savings -> emergency + vacation)
|
|
97
|
+
- Allocation percentages must sum to <=100% per account
|
|
100
98
|
"""
|
|
101
99
|
|
|
102
100
|
goal_id: str = Field(..., description="Goal identifier")
|
|
@@ -107,7 +105,7 @@ class FundingSource(BaseModel):
|
|
|
107
105
|
ge=0.0,
|
|
108
106
|
le=100.0,
|
|
109
107
|
)
|
|
110
|
-
account_name:
|
|
108
|
+
account_name: str | None = Field(
|
|
111
109
|
None, description="Human-readable account name (e.g., 'Chase Savings')"
|
|
112
110
|
)
|
|
113
111
|
|
|
@@ -154,9 +152,7 @@ class Goal(BaseModel):
|
|
|
154
152
|
id: str = Field(..., description="Unique goal identifier")
|
|
155
153
|
user_id: str = Field(..., description="User who owns this goal")
|
|
156
154
|
name: str = Field(..., description="Goal name", max_length=200)
|
|
157
|
-
description:
|
|
158
|
-
None, description="Detailed goal description", max_length=1000
|
|
159
|
-
)
|
|
155
|
+
description: str | None = Field(None, description="Detailed goal description", max_length=1000)
|
|
160
156
|
|
|
161
157
|
# Goal type and status
|
|
162
158
|
type: GoalType = Field(..., description="Goal type")
|
|
@@ -165,7 +161,7 @@ class Goal(BaseModel):
|
|
|
165
161
|
# Financial targets
|
|
166
162
|
target_amount: float = Field(..., description="Target amount to achieve", gt=0)
|
|
167
163
|
current_amount: float = Field(default=0.0, description="Current progress toward target", ge=0.0)
|
|
168
|
-
deadline:
|
|
164
|
+
deadline: datetime | None = Field(None, description="Target completion date")
|
|
169
165
|
|
|
170
166
|
# Milestone tracking
|
|
171
167
|
milestones: list[Milestone] = Field(default_factory=list, description="Progress milestones")
|
|
@@ -190,7 +186,7 @@ class Goal(BaseModel):
|
|
|
190
186
|
updated_at: datetime = Field(
|
|
191
187
|
default_factory=datetime.utcnow, description="Last update timestamp"
|
|
192
188
|
)
|
|
193
|
-
completed_at:
|
|
189
|
+
completed_at: datetime | None = Field(
|
|
194
190
|
None, description="Completion timestamp (if status=COMPLETED)"
|
|
195
191
|
)
|
|
196
192
|
|
|
@@ -264,7 +260,7 @@ class GoalProgress(BaseModel):
|
|
|
264
260
|
)
|
|
265
261
|
|
|
266
262
|
# Projections
|
|
267
|
-
projected_completion_date:
|
|
263
|
+
projected_completion_date: datetime | None = Field(
|
|
268
264
|
None, description="Projected completion date at current pace"
|
|
269
265
|
)
|
|
270
266
|
on_track: bool = Field(..., description="Whether on track to meet deadline")
|
fin_infra/insights/__init__.py
CHANGED
|
@@ -10,13 +10,16 @@ Aggregates insights from multiple sources:
|
|
|
10
10
|
- Cash flow projections
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import logging
|
|
13
14
|
from typing import TYPE_CHECKING
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from fastapi import FastAPI
|
|
17
18
|
|
|
18
|
-
from .models import Insight, InsightFeed, InsightPriority, InsightCategory
|
|
19
19
|
from .aggregator import aggregate_insights, get_user_insights
|
|
20
|
+
from .models import Insight, InsightCategory, InsightFeed, InsightPriority
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
__all__ = [
|
|
22
25
|
"Insight",
|
|
@@ -77,10 +80,10 @@ def add_insights(
|
|
|
77
80
|
- Notification system for critical insights
|
|
78
81
|
"""
|
|
79
82
|
from fastapi import Query
|
|
83
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
80
84
|
|
|
81
85
|
# Import svc-infra user router (requires auth)
|
|
82
86
|
from svc_infra.api.fastapi.dual.protected import user_router
|
|
83
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
84
87
|
|
|
85
88
|
# Create router
|
|
86
89
|
router = user_router(prefix=prefix, tags=["Insights"])
|
|
@@ -125,4 +128,4 @@ def add_insights(
|
|
|
125
128
|
# Mount router
|
|
126
129
|
app.include_router(router, include_in_schema=True)
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
logger.info("Insights feed enabled")
|
fin_infra/insights/aggregator.py
CHANGED
|
@@ -224,7 +224,7 @@ def _generate_recurring_insights(user_id: str, patterns: list[RecurringPattern])
|
|
|
224
224
|
high_cost = [
|
|
225
225
|
p
|
|
226
226
|
for p in patterns
|
|
227
|
-
if p.amount is not None and p.amount > 50 or (p.amount_range and p.amount_range[1] > 50)
|
|
227
|
+
if (p.amount is not None and p.amount > 50) or (p.amount_range and p.amount_range[1] > 50)
|
|
228
228
|
]
|
|
229
229
|
if high_cost:
|
|
230
230
|
total = Decimal("0")
|
|
@@ -74,8 +74,8 @@ def easy_investments(
|
|
|
74
74
|
InvestmentProvider instance for fetching holdings, transactions, securities.
|
|
75
75
|
|
|
76
76
|
Environment detection order:
|
|
77
|
-
1. If PLAID_CLIENT_ID set
|
|
78
|
-
2. If SNAPTRADE_CLIENT_ID set
|
|
77
|
+
1. If PLAID_CLIENT_ID set -> Plaid
|
|
78
|
+
2. If SNAPTRADE_CLIENT_ID set -> SnapTrade
|
|
79
79
|
3. Default: Plaid (most common)
|
|
80
80
|
|
|
81
81
|
Examples:
|
|
@@ -110,7 +110,7 @@ def easy_investments(
|
|
|
110
110
|
provider = "plaid" # Default to Plaid
|
|
111
111
|
|
|
112
112
|
# Check cache
|
|
113
|
-
cache_key = f"{provider}:{
|
|
113
|
+
cache_key = f"{provider}:{sorted(config.items())!s}"
|
|
114
114
|
if cache_key in _provider_cache:
|
|
115
115
|
return _provider_cache[cache_key]
|
|
116
116
|
|