fin-infra 0.1.81__py3-none-any.whl → 0.1.83__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/__init__.py +3 -2
- fin_infra/analytics/add.py +21 -21
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +6 -6
- fin_infra/analytics/projections.py +1 -3
- fin_infra/banking/__init__.py +27 -27
- fin_infra/banking/history.py +4 -5
- fin_infra/banking/utils.py +19 -18
- fin_infra/brokerage/__init__.py +22 -24
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/cashflows/__init__.py +2 -2
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/engine.py +6 -6
- fin_infra/categorization/llm_layer.py +6 -5
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +5 -5
- fin_infra/chat/planning.py +0 -1
- fin_infra/cli/cmds/scaffold_cmds.py +10 -11
- fin_infra/clients/plaid.py +1 -1
- fin_infra/compliance/__init__.py +5 -5
- 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/provider.py +4 -4
- fin_infra/crypto/__init__.py +7 -9
- fin_infra/documents/add.py +6 -8
- fin_infra/documents/analysis.py +8 -8
- fin_infra/documents/ease.py +14 -14
- 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 +30 -30
- fin_infra/goals/funding.py +1 -1
- fin_infra/goals/management.py +2 -3
- fin_infra/goals/milestones.py +1 -2
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +2 -2
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +1 -1
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/providers/base.py +2 -3
- fin_infra/investments/providers/plaid.py +9 -9
- fin_infra/investments/providers/snaptrade.py +10 -10
- fin_infra/markets/__init__.py +1 -1
- fin_infra/models/__init__.py +10 -10
- 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 +3 -4
- fin_infra/net_worth/insights.py +0 -1
- fin_infra/normalization/__init__.py +2 -2
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/providers/banking/plaid_client.py +5 -5
- fin_infra/providers/banking/teller_client.py +7 -6
- fin_infra/providers/base.py +1 -1
- fin_infra/providers/brokerage/alpaca.py +3 -3
- fin_infra/providers/market/alphavantage.py +5 -10
- fin_infra/providers/market/ccxt_crypto.py +2 -2
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +5 -5
- 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 +5 -4
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +6 -6
- fin_infra/recurring/ease.py +2 -4
- fin_infra/recurring/insights.py +13 -13
- fin_infra/recurring/normalizer.py +1 -1
- fin_infra/recurring/normalizers.py +4 -4
- fin_infra/recurring/summary.py +4 -6
- fin_infra/scaffold/budgets.py +6 -6
- fin_infra/scaffold/goals.py +1 -1
- 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/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +1 -1
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/METADATA +1 -1
- fin_infra-0.1.83.dist-info/RECORD +180 -0
- fin_infra-0.1.81.dist-info/RECORD +0 -180
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/__init__.py
CHANGED
|
@@ -55,10 +55,11 @@ Dependencies:
|
|
|
55
55
|
|
|
56
56
|
from __future__ import annotations
|
|
57
57
|
|
|
58
|
-
# Import actual implementations
|
|
59
|
-
from .ease import easy_analytics, AnalyticsEngine
|
|
60
58
|
from .add import add_analytics
|
|
61
59
|
|
|
60
|
+
# Import actual implementations
|
|
61
|
+
from .ease import AnalyticsEngine, easy_analytics
|
|
62
|
+
|
|
62
63
|
__all__ = [
|
|
63
64
|
"easy_analytics",
|
|
64
65
|
"add_analytics",
|
fin_infra/analytics/add.py
CHANGED
|
@@ -7,7 +7,7 @@ MUST use svc-infra dual routers (user_router) - NEVER generic APIRouter.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from datetime import datetime
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
11
|
|
|
12
12
|
from fastapi import HTTPException, Query
|
|
13
13
|
from pydantic import BaseModel, Field
|
|
@@ -15,15 +15,15 @@ from pydantic import BaseModel, Field
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from fastapi import FastAPI
|
|
17
17
|
|
|
18
|
-
from .ease import
|
|
18
|
+
from .ease import AnalyticsEngine, easy_analytics
|
|
19
19
|
from .models import (
|
|
20
|
+
BenchmarkComparison,
|
|
20
21
|
CashFlowAnalysis,
|
|
21
|
-
|
|
22
|
-
SpendingInsight,
|
|
22
|
+
GrowthProjection,
|
|
23
23
|
PersonalizedSpendingAdvice,
|
|
24
24
|
PortfolioMetrics,
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
SavingsRateData,
|
|
26
|
+
SpendingInsight,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
|
|
@@ -33,15 +33,15 @@ class NetWorthForecastRequest(BaseModel):
|
|
|
33
33
|
|
|
34
34
|
user_id: str = Field(..., description="User identifier")
|
|
35
35
|
years: int = Field(default=30, ge=1, le=50, description="Projection years (1-50)")
|
|
36
|
-
initial_net_worth:
|
|
37
|
-
annual_contribution:
|
|
38
|
-
conservative_return:
|
|
36
|
+
initial_net_worth: float | None = Field(None, description="Override initial net worth")
|
|
37
|
+
annual_contribution: float | None = Field(None, description="Annual savings contribution")
|
|
38
|
+
conservative_return: float | None = Field(
|
|
39
39
|
None, description="Conservative return rate (e.g., 0.05 = 5%)"
|
|
40
40
|
)
|
|
41
|
-
moderate_return:
|
|
41
|
+
moderate_return: float | None = Field(
|
|
42
42
|
None, description="Moderate return rate (e.g., 0.07 = 7%)"
|
|
43
43
|
)
|
|
44
|
-
aggressive_return:
|
|
44
|
+
aggressive_return: float | None = Field(
|
|
45
45
|
None, description="Aggressive return rate (e.g., 0.10 = 10%)"
|
|
46
46
|
)
|
|
47
47
|
|
|
@@ -49,7 +49,7 @@ class NetWorthForecastRequest(BaseModel):
|
|
|
49
49
|
def add_analytics(
|
|
50
50
|
app: FastAPI,
|
|
51
51
|
prefix: str = "/analytics",
|
|
52
|
-
provider:
|
|
52
|
+
provider: AnalyticsEngine | None = None,
|
|
53
53
|
include_in_schema: bool = True,
|
|
54
54
|
) -> AnalyticsEngine:
|
|
55
55
|
"""Add analytics endpoints to FastAPI application.
|
|
@@ -124,9 +124,9 @@ def add_analytics(
|
|
|
124
124
|
)
|
|
125
125
|
async def get_cash_flow(
|
|
126
126
|
user_id: str,
|
|
127
|
-
start_date:
|
|
128
|
-
end_date:
|
|
129
|
-
period_days:
|
|
127
|
+
start_date: datetime | None = None,
|
|
128
|
+
end_date: datetime | None = None,
|
|
129
|
+
period_days: int | None = None,
|
|
130
130
|
) -> CashFlowAnalysis:
|
|
131
131
|
"""
|
|
132
132
|
Calculate cash flow analysis for a user.
|
|
@@ -164,7 +164,7 @@ def add_analytics(
|
|
|
164
164
|
)
|
|
165
165
|
async def get_spending_insights(
|
|
166
166
|
user_id: str,
|
|
167
|
-
period_days:
|
|
167
|
+
period_days: int | None = None,
|
|
168
168
|
include_trends: bool = True,
|
|
169
169
|
) -> SpendingInsight:
|
|
170
170
|
"""
|
|
@@ -186,7 +186,7 @@ def add_analytics(
|
|
|
186
186
|
)
|
|
187
187
|
async def get_spending_advice(
|
|
188
188
|
user_id: str,
|
|
189
|
-
period_days:
|
|
189
|
+
period_days: int | None = None,
|
|
190
190
|
) -> PersonalizedSpendingAdvice:
|
|
191
191
|
"""
|
|
192
192
|
Generate personalized spending advice using AI.
|
|
@@ -206,11 +206,11 @@ def add_analytics(
|
|
|
206
206
|
)
|
|
207
207
|
async def get_portfolio_metrics(
|
|
208
208
|
user_id: str,
|
|
209
|
-
accounts:
|
|
209
|
+
accounts: list[str] | None = None,
|
|
210
210
|
with_holdings: bool = Query(
|
|
211
211
|
False, description="Use real holdings data from investment provider for accurate P/L"
|
|
212
212
|
),
|
|
213
|
-
access_token:
|
|
213
|
+
access_token: str | None = Query(
|
|
214
214
|
None, description="Investment provider access token (required if with_holdings=true)"
|
|
215
215
|
),
|
|
216
216
|
) -> PortfolioMetrics:
|
|
@@ -286,9 +286,9 @@ def add_analytics(
|
|
|
286
286
|
)
|
|
287
287
|
async def get_benchmark_comparison(
|
|
288
288
|
user_id: str,
|
|
289
|
-
benchmark:
|
|
289
|
+
benchmark: str | None = None,
|
|
290
290
|
period: str = "1y",
|
|
291
|
-
accounts:
|
|
291
|
+
accounts: list[str] | None = None,
|
|
292
292
|
) -> BenchmarkComparison:
|
|
293
293
|
"""
|
|
294
294
|
Compare portfolio to benchmark (e.g., SPY, VTI).
|
fin_infra/analytics/ease.py
CHANGED
|
@@ -16,23 +16,22 @@ Typical usage:
|
|
|
16
16
|
portfolio = await analytics.portfolio_metrics(user_id="user123")
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
from typing import Optional
|
|
20
19
|
from datetime import datetime, timedelta
|
|
21
20
|
|
|
22
21
|
from .cash_flow import calculate_cash_flow
|
|
23
|
-
from .savings import calculate_savings_rate, SavingsDefinition
|
|
24
|
-
from .spending import analyze_spending, generate_spending_insights
|
|
25
|
-
from .portfolio import calculate_portfolio_metrics, compare_to_benchmark
|
|
26
|
-
from .projections import project_net_worth, calculate_compound_interest
|
|
27
22
|
from .models import (
|
|
23
|
+
BenchmarkComparison,
|
|
28
24
|
CashFlowAnalysis,
|
|
29
|
-
|
|
30
|
-
SpendingInsight,
|
|
25
|
+
GrowthProjection,
|
|
31
26
|
PersonalizedSpendingAdvice,
|
|
32
27
|
PortfolioMetrics,
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
SavingsRateData,
|
|
29
|
+
SpendingInsight,
|
|
35
30
|
)
|
|
31
|
+
from .portfolio import calculate_portfolio_metrics, compare_to_benchmark
|
|
32
|
+
from .projections import calculate_compound_interest, project_net_worth
|
|
33
|
+
from .savings import SavingsDefinition, calculate_savings_rate
|
|
34
|
+
from .spending import analyze_spending, generate_spending_insights
|
|
36
35
|
|
|
37
36
|
|
|
38
37
|
class AnalyticsEngine:
|
|
@@ -93,9 +92,9 @@ class AnalyticsEngine:
|
|
|
93
92
|
self,
|
|
94
93
|
user_id: str,
|
|
95
94
|
*,
|
|
96
|
-
start_date:
|
|
97
|
-
end_date:
|
|
98
|
-
period_days:
|
|
95
|
+
start_date: datetime | None = None,
|
|
96
|
+
end_date: datetime | None = None,
|
|
97
|
+
period_days: int | None = None,
|
|
99
98
|
) -> CashFlowAnalysis:
|
|
100
99
|
"""Analyze cash flow (income vs expenses).
|
|
101
100
|
|
|
@@ -129,7 +128,7 @@ class AnalyticsEngine:
|
|
|
129
128
|
self,
|
|
130
129
|
user_id: str,
|
|
131
130
|
*,
|
|
132
|
-
definition:
|
|
131
|
+
definition: str | SavingsDefinition | None = None,
|
|
133
132
|
period: str = "monthly",
|
|
134
133
|
) -> SavingsRateData:
|
|
135
134
|
"""Calculate savings rate.
|
|
@@ -163,7 +162,7 @@ class AnalyticsEngine:
|
|
|
163
162
|
self,
|
|
164
163
|
user_id: str,
|
|
165
164
|
*,
|
|
166
|
-
period_days:
|
|
165
|
+
period_days: int | None = None,
|
|
167
166
|
include_trends: bool = True,
|
|
168
167
|
) -> SpendingInsight:
|
|
169
168
|
"""Analyze spending patterns and generate insights.
|
|
@@ -193,8 +192,8 @@ class AnalyticsEngine:
|
|
|
193
192
|
self,
|
|
194
193
|
user_id: str,
|
|
195
194
|
*,
|
|
196
|
-
period_days:
|
|
197
|
-
user_context:
|
|
195
|
+
period_days: int | None = None,
|
|
196
|
+
user_context: dict | None = None,
|
|
198
197
|
) -> PersonalizedSpendingAdvice:
|
|
199
198
|
"""Generate AI-powered personalized spending advice.
|
|
200
199
|
|
|
@@ -228,7 +227,7 @@ class AnalyticsEngine:
|
|
|
228
227
|
self,
|
|
229
228
|
user_id: str,
|
|
230
229
|
*,
|
|
231
|
-
accounts:
|
|
230
|
+
accounts: list[str] | None = None,
|
|
232
231
|
) -> PortfolioMetrics:
|
|
233
232
|
"""Calculate portfolio performance metrics.
|
|
234
233
|
|
|
@@ -250,9 +249,9 @@ class AnalyticsEngine:
|
|
|
250
249
|
self,
|
|
251
250
|
user_id: str,
|
|
252
251
|
*,
|
|
253
|
-
benchmark:
|
|
252
|
+
benchmark: str | None = None,
|
|
254
253
|
period: str = "1y",
|
|
255
|
-
accounts:
|
|
254
|
+
accounts: list[str] | None = None,
|
|
256
255
|
) -> BenchmarkComparison:
|
|
257
256
|
"""Compare portfolio to benchmark index.
|
|
258
257
|
|
|
@@ -282,7 +281,7 @@ class AnalyticsEngine:
|
|
|
282
281
|
user_id: str,
|
|
283
282
|
*,
|
|
284
283
|
years: int = 30,
|
|
285
|
-
assumptions:
|
|
284
|
+
assumptions: dict | None = None,
|
|
286
285
|
) -> GrowthProjection:
|
|
287
286
|
"""Project net worth growth with scenarios.
|
|
288
287
|
|
fin_infra/analytics/portfolio.py
CHANGED
|
@@ -35,7 +35,6 @@ Examples:
|
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
37
|
from datetime import datetime
|
|
38
|
-
from typing import Optional
|
|
39
38
|
|
|
40
39
|
from fin_infra.analytics.models import (
|
|
41
40
|
AssetAllocation,
|
|
@@ -47,7 +46,7 @@ from fin_infra.analytics.models import (
|
|
|
47
46
|
async def calculate_portfolio_metrics(
|
|
48
47
|
user_id: str,
|
|
49
48
|
*,
|
|
50
|
-
accounts:
|
|
49
|
+
accounts: list[str] | None = None,
|
|
51
50
|
brokerage_provider=None,
|
|
52
51
|
market_provider=None,
|
|
53
52
|
) -> PortfolioMetrics:
|
|
@@ -131,7 +130,7 @@ async def compare_to_benchmark(
|
|
|
131
130
|
*,
|
|
132
131
|
benchmark: str = "SPY",
|
|
133
132
|
period: str = "1y",
|
|
134
|
-
accounts:
|
|
133
|
+
accounts: list[str] | None = None,
|
|
135
134
|
brokerage_provider=None,
|
|
136
135
|
market_provider=None,
|
|
137
136
|
) -> BenchmarkComparison:
|
|
@@ -221,7 +220,7 @@ async def compare_to_benchmark(
|
|
|
221
220
|
|
|
222
221
|
def _generate_mock_holdings(
|
|
223
222
|
user_id: str,
|
|
224
|
-
accounts:
|
|
223
|
+
accounts: list[str] | None = None,
|
|
225
224
|
) -> list[dict]:
|
|
226
225
|
"""Generate mock portfolio holdings for testing.
|
|
227
226
|
|
|
@@ -415,7 +414,7 @@ def _parse_benchmark_period(period: str) -> int:
|
|
|
415
414
|
def _calculate_portfolio_return(
|
|
416
415
|
user_id: str,
|
|
417
416
|
period_days: int,
|
|
418
|
-
accounts:
|
|
417
|
+
accounts: list[str] | None = None,
|
|
419
418
|
) -> tuple[float, float]:
|
|
420
419
|
"""Calculate portfolio return for specified period.
|
|
421
420
|
|
|
@@ -483,7 +482,7 @@ def _calculate_beta(
|
|
|
483
482
|
user_id: str,
|
|
484
483
|
benchmark: str,
|
|
485
484
|
period_days: int,
|
|
486
|
-
) ->
|
|
485
|
+
) -> float | None:
|
|
487
486
|
"""Calculate portfolio beta (volatility relative to benchmark).
|
|
488
487
|
|
|
489
488
|
Beta = Covariance(portfolio_returns, benchmark_returns) / Variance(benchmark_returns)
|
|
@@ -713,6 +712,7 @@ def _calculate_allocation_from_holdings(
|
|
|
713
712
|
- other → Other
|
|
714
713
|
"""
|
|
715
714
|
from collections import defaultdict
|
|
715
|
+
|
|
716
716
|
from .models import AssetAllocation
|
|
717
717
|
|
|
718
718
|
if total_value == 0:
|
|
@@ -19,12 +19,10 @@ Typical usage:
|
|
|
19
19
|
)
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
-
from typing import Optional
|
|
23
22
|
import math
|
|
24
23
|
|
|
25
24
|
from fin_infra.analytics.models import GrowthProjection, Scenario
|
|
26
25
|
|
|
27
|
-
|
|
28
26
|
# ============================================================================
|
|
29
27
|
# Public API
|
|
30
28
|
# ============================================================================
|
|
@@ -34,7 +32,7 @@ async def project_net_worth(
|
|
|
34
32
|
user_id: str,
|
|
35
33
|
*,
|
|
36
34
|
years: int = 30,
|
|
37
|
-
assumptions:
|
|
35
|
+
assumptions: dict | None = None,
|
|
38
36
|
net_worth_provider=None,
|
|
39
37
|
cash_flow_provider=None,
|
|
40
38
|
) -> GrowthProjection:
|
fin_infra/banking/__init__.py
CHANGED
|
@@ -45,12 +45,12 @@ from __future__ import annotations
|
|
|
45
45
|
|
|
46
46
|
import os
|
|
47
47
|
from datetime import date
|
|
48
|
-
from typing import TYPE_CHECKING,
|
|
48
|
+
from typing import TYPE_CHECKING, cast
|
|
49
49
|
|
|
50
50
|
from pydantic import BaseModel, Field
|
|
51
51
|
|
|
52
|
-
from ..providers.registry import resolve
|
|
53
52
|
from ..providers.base import BankingProvider
|
|
53
|
+
from ..providers.registry import resolve
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
56
|
from fastapi import FastAPI
|
|
@@ -99,7 +99,7 @@ class ExchangeTokenResponse(BaseModel):
|
|
|
99
99
|
"""Response model for token exchange."""
|
|
100
100
|
|
|
101
101
|
access_token: str
|
|
102
|
-
item_id:
|
|
102
|
+
item_id: str | None = None
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
class BalanceHistoryStats(BaseModel):
|
|
@@ -198,11 +198,11 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
# Use provider registry to dynamically load and configure provider
|
|
201
|
-
return cast(BankingProvider, resolve("banking", provider, **config))
|
|
201
|
+
return cast("BankingProvider", resolve("banking", provider, **config))
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def add_banking(
|
|
205
|
-
app:
|
|
205
|
+
app: FastAPI,
|
|
206
206
|
*,
|
|
207
207
|
provider: str | BankingProvider | None = None,
|
|
208
208
|
prefix: str = "/banking",
|
|
@@ -349,25 +349,25 @@ def add_banking(
|
|
|
349
349
|
@router.get("/transactions")
|
|
350
350
|
async def get_transactions(
|
|
351
351
|
access_token: str = Depends(get_access_token),
|
|
352
|
-
start_date:
|
|
353
|
-
end_date:
|
|
354
|
-
merchant:
|
|
352
|
+
start_date: date | None = Query(None, description="Filter by start date (ISO format)"),
|
|
353
|
+
end_date: date | None = Query(None, description="Filter by end date (ISO format)"),
|
|
354
|
+
merchant: str | None = Query(
|
|
355
355
|
None, description="Filter by merchant name (partial match, case-insensitive)"
|
|
356
356
|
),
|
|
357
|
-
category:
|
|
357
|
+
category: str | None = Query(
|
|
358
358
|
None, description="Filter by category (comma-separated list for multiple)"
|
|
359
359
|
),
|
|
360
|
-
min_amount:
|
|
360
|
+
min_amount: float | None = Query(
|
|
361
361
|
None, description="Minimum transaction amount (inclusive)"
|
|
362
362
|
),
|
|
363
|
-
max_amount:
|
|
363
|
+
max_amount: float | None = Query(
|
|
364
364
|
None, description="Maximum transaction amount (inclusive)"
|
|
365
365
|
),
|
|
366
|
-
tags:
|
|
367
|
-
account_id:
|
|
368
|
-
is_recurring:
|
|
369
|
-
sort_by:
|
|
370
|
-
order:
|
|
366
|
+
tags: str | None = Query(None, description="Filter by tags (comma-separated list)"),
|
|
367
|
+
account_id: str | None = Query(None, description="Filter by specific account ID"),
|
|
368
|
+
is_recurring: bool | None = Query(None, description="Filter by recurring status"),
|
|
369
|
+
sort_by: str | None = Query("date", description="Sort field: date, amount, or merchant"),
|
|
370
|
+
order: str | None = Query("desc", description="Sort order: asc or desc"),
|
|
371
371
|
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
|
372
372
|
per_page: int = Query(50, ge=1, le=200, description="Items per page (max 200)"),
|
|
373
373
|
):
|
|
@@ -475,7 +475,7 @@ def add_banking(
|
|
|
475
475
|
@router.get("/balances")
|
|
476
476
|
async def get_balances(
|
|
477
477
|
access_token: str = Depends(get_access_token),
|
|
478
|
-
account_id:
|
|
478
|
+
account_id: str | None = Query(None),
|
|
479
479
|
):
|
|
480
480
|
"""Get current balances."""
|
|
481
481
|
balances = banking.balances(
|
|
@@ -592,17 +592,17 @@ def add_banking(
|
|
|
592
592
|
|
|
593
593
|
# Import utilities at end to avoid circular imports
|
|
594
594
|
from .utils import ( # noqa: E402
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
595
|
+
BankingConnectionInfo,
|
|
596
|
+
BankingConnectionStatus,
|
|
597
|
+
get_primary_access_token,
|
|
598
|
+
mark_connection_healthy,
|
|
599
|
+
mark_connection_unhealthy,
|
|
599
600
|
parse_banking_providers,
|
|
600
601
|
sanitize_connection_status,
|
|
601
|
-
mark_connection_unhealthy,
|
|
602
|
-
mark_connection_healthy,
|
|
603
|
-
get_primary_access_token,
|
|
604
|
-
test_connection_health,
|
|
605
602
|
should_refresh_token,
|
|
606
|
-
|
|
607
|
-
|
|
603
|
+
test_connection_health,
|
|
604
|
+
validate_mx_token,
|
|
605
|
+
validate_plaid_token,
|
|
606
|
+
validate_provider_token,
|
|
607
|
+
validate_teller_token,
|
|
608
608
|
)
|
fin_infra/banking/history.py
CHANGED
|
@@ -42,9 +42,8 @@ from __future__ import annotations
|
|
|
42
42
|
import logging
|
|
43
43
|
import os
|
|
44
44
|
from datetime import date, datetime, timedelta
|
|
45
|
-
from typing import Optional
|
|
46
|
-
from pydantic import BaseModel, Field, ConfigDict
|
|
47
45
|
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
48
47
|
|
|
49
48
|
__all__ = [
|
|
50
49
|
"BalanceSnapshot",
|
|
@@ -155,8 +154,8 @@ def record_balance_snapshot(
|
|
|
155
154
|
def get_balance_history(
|
|
156
155
|
account_id: str,
|
|
157
156
|
days: int = 90,
|
|
158
|
-
start_date:
|
|
159
|
-
end_date:
|
|
157
|
+
start_date: date | None = None,
|
|
158
|
+
end_date: date | None = None,
|
|
160
159
|
) -> list[BalanceSnapshot]:
|
|
161
160
|
"""Get balance history for an account.
|
|
162
161
|
|
|
@@ -248,7 +247,7 @@ def get_balance_snapshots(
|
|
|
248
247
|
|
|
249
248
|
def delete_balance_history(
|
|
250
249
|
account_id: str,
|
|
251
|
-
before_date:
|
|
250
|
+
before_date: date | None = None,
|
|
252
251
|
) -> int:
|
|
253
252
|
"""Delete balance history for an account.
|
|
254
253
|
|
fin_infra/banking/utils.py
CHANGED
|
@@ -8,8 +8,9 @@ Apps still manage user-to-token mappings, but these utilities simplify common op
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
|
-
from datetime import
|
|
12
|
-
from typing import Any,
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
13
14
|
from pydantic import BaseModel, ConfigDict, Field
|
|
14
15
|
|
|
15
16
|
from ..providers.base import BankingProvider
|
|
@@ -22,23 +23,23 @@ class BankingConnectionInfo(BaseModel):
|
|
|
22
23
|
|
|
23
24
|
provider: Literal["plaid", "teller", "mx"]
|
|
24
25
|
connected: bool
|
|
25
|
-
access_token:
|
|
26
|
+
access_token: str | None = Field(
|
|
26
27
|
None, description="Token (only for internal use, never expose)"
|
|
27
28
|
)
|
|
28
|
-
item_id:
|
|
29
|
-
enrollment_id:
|
|
30
|
-
connected_at:
|
|
31
|
-
last_synced_at:
|
|
29
|
+
item_id: str | None = None
|
|
30
|
+
enrollment_id: str | None = None
|
|
31
|
+
connected_at: datetime | None = None
|
|
32
|
+
last_synced_at: datetime | None = None
|
|
32
33
|
is_healthy: bool = True
|
|
33
|
-
error_message:
|
|
34
|
+
error_message: str | None = None
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class BankingConnectionStatus(BaseModel):
|
|
37
38
|
"""Status of all banking connections for a user."""
|
|
38
39
|
|
|
39
|
-
plaid:
|
|
40
|
-
teller:
|
|
41
|
-
mx:
|
|
40
|
+
plaid: BankingConnectionInfo | None = None
|
|
41
|
+
teller: BankingConnectionInfo | None = None
|
|
42
|
+
mx: BankingConnectionInfo | None = None
|
|
42
43
|
has_any_connection: bool = False
|
|
43
44
|
|
|
44
45
|
@property
|
|
@@ -54,7 +55,7 @@ class BankingConnectionStatus(BaseModel):
|
|
|
54
55
|
return providers
|
|
55
56
|
|
|
56
57
|
@property
|
|
57
|
-
def primary_provider(self) ->
|
|
58
|
+
def primary_provider(self) -> str | None:
|
|
58
59
|
"""Primary provider (first connected, or most recently synced)."""
|
|
59
60
|
if not self.has_any_connection:
|
|
60
61
|
return None
|
|
@@ -329,7 +330,7 @@ def mark_connection_unhealthy(
|
|
|
329
330
|
|
|
330
331
|
banking_providers[provider]["is_healthy"] = False
|
|
331
332
|
banking_providers[provider]["error_message"] = error_message
|
|
332
|
-
banking_providers[provider]["error_at"] = datetime.now(
|
|
333
|
+
banking_providers[provider]["error_at"] = datetime.now(UTC).isoformat()
|
|
333
334
|
|
|
334
335
|
return banking_providers
|
|
335
336
|
|
|
@@ -362,14 +363,14 @@ def mark_connection_healthy(
|
|
|
362
363
|
|
|
363
364
|
banking_providers[provider]["is_healthy"] = True
|
|
364
365
|
banking_providers[provider]["error_message"] = None
|
|
365
|
-
banking_providers[provider]["last_synced_at"] = datetime.now(
|
|
366
|
+
banking_providers[provider]["last_synced_at"] = datetime.now(UTC).isoformat()
|
|
366
367
|
|
|
367
368
|
return banking_providers
|
|
368
369
|
|
|
369
370
|
|
|
370
371
|
def get_primary_access_token(
|
|
371
372
|
banking_providers: dict[str, Any],
|
|
372
|
-
) -> tuple[
|
|
373
|
+
) -> tuple[str | None, str | None]:
|
|
373
374
|
"""
|
|
374
375
|
Get the primary access token and provider name.
|
|
375
376
|
|
|
@@ -401,7 +402,7 @@ def get_primary_access_token(
|
|
|
401
402
|
async def test_connection_health(
|
|
402
403
|
provider: BankingProvider,
|
|
403
404
|
access_token: str,
|
|
404
|
-
) -> tuple[bool,
|
|
405
|
+
) -> tuple[bool, str | None]:
|
|
405
406
|
"""
|
|
406
407
|
Test if a banking connection is healthy by making a lightweight API call.
|
|
407
408
|
|
|
@@ -468,14 +469,14 @@ def should_refresh_token(banking_providers: dict[str, Any], provider: str) -> bo
|
|
|
468
469
|
last_synced = _parse_datetime(last_synced_str)
|
|
469
470
|
if last_synced:
|
|
470
471
|
# Refresh if not synced in 30 days
|
|
471
|
-
days_since_sync = (datetime.now(
|
|
472
|
+
days_since_sync = (datetime.now(UTC) - last_synced).days
|
|
472
473
|
if days_since_sync > 30:
|
|
473
474
|
return True
|
|
474
475
|
|
|
475
476
|
return False
|
|
476
477
|
|
|
477
478
|
|
|
478
|
-
def _parse_datetime(value: Any) ->
|
|
479
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
479
480
|
"""Parse datetime from various formats."""
|
|
480
481
|
if not value:
|
|
481
482
|
return None
|