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/recurring/insights.py
CHANGED
|
@@ -7,7 +7,7 @@ Provides on-demand insights for users:
|
|
|
7
7
|
- Cost-saving recommendations (bundle deals, unused subscriptions)
|
|
8
8
|
|
|
9
9
|
Uses ai-infra LLM with few-shot prompting.
|
|
10
|
-
Caches results for 24 hours (80% hit rate expected)
|
|
10
|
+
Caches results for 24 hours (80% hit rate expected) -> <1ms latency.
|
|
11
11
|
Triggered via GET /recurring/insights API endpoint (not automatic).
|
|
12
12
|
"""
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import Any,
|
|
18
|
+
from typing import Any, cast
|
|
19
19
|
|
|
20
20
|
from pydantic import BaseModel, ConfigDict, Field
|
|
21
21
|
|
|
@@ -61,7 +61,7 @@ class SubscriptionInsights(BaseModel):
|
|
|
61
61
|
ge=0.0,
|
|
62
62
|
description="Total monthly subscription cost",
|
|
63
63
|
)
|
|
64
|
-
potential_savings:
|
|
64
|
+
potential_savings: float | None = Field(
|
|
65
65
|
None,
|
|
66
66
|
ge=0.0,
|
|
67
67
|
description="Potential monthly savings from recommendations (if applicable)",
|
|
@@ -105,23 +105,23 @@ Guidelines:
|
|
|
105
105
|
|
|
106
106
|
Examples:
|
|
107
107
|
1. Subscriptions: Netflix $15.99, Hulu $12.99, Disney+ $10.99, Spotify $9.99, Amazon Prime $14.99
|
|
108
|
-
|
|
109
|
-
(Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
|
|
108
|
+
-> "You have 5 subscriptions totaling $64.95/month. Consider the Disney+ bundle
|
|
109
|
+
(Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
|
|
110
110
|
includes Prime Video - you may be able to cancel Netflix or Hulu."
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
-> total_monthly_cost: 64.95
|
|
112
|
+
-> potential_savings: 30.00
|
|
113
113
|
|
|
114
114
|
2. Subscriptions: Spotify $9.99, Apple Music $10.99
|
|
115
|
-
|
|
115
|
+
-> "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
|
|
116
116
|
to save $10.99/month."
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
-> total_monthly_cost: 20.98
|
|
118
|
+
-> potential_savings: 10.99
|
|
119
119
|
|
|
120
120
|
3. Subscriptions: LA Fitness $40, Planet Fitness $10
|
|
121
|
-
|
|
121
|
+
-> "You have 2 gym memberships totaling $50/month. Consider consolidating to
|
|
122
122
|
just Planet Fitness to save $40/month."
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
-> total_monthly_cost: 50.00
|
|
124
|
+
-> potential_savings: 40.00
|
|
125
125
|
|
|
126
126
|
Output format (JSON):
|
|
127
127
|
{
|
|
@@ -152,8 +152,8 @@ class SubscriptionInsightsGenerator:
|
|
|
152
152
|
LLM-based subscription insights generator with caching.
|
|
153
153
|
|
|
154
154
|
Layer 5 of 4-layer hybrid architecture (on-demand, optional):
|
|
155
|
-
1. Check cache first (80% hit rate, 24h TTL)
|
|
156
|
-
2. Call LLM if cache miss
|
|
155
|
+
1. Check cache first (80% hit rate, 24h TTL) -> <1ms
|
|
156
|
+
2. Call LLM if cache miss -> 300-500ms
|
|
157
157
|
3. Cache result for 24 hours
|
|
158
158
|
4. Return SubscriptionInsights
|
|
159
159
|
|
|
@@ -163,7 +163,7 @@ class SubscriptionInsightsGenerator:
|
|
|
163
163
|
def __init__(
|
|
164
164
|
self,
|
|
165
165
|
provider: str = "google",
|
|
166
|
-
model_name:
|
|
166
|
+
model_name: str | None = None,
|
|
167
167
|
cache_ttl: int = 86400, # 24 hours
|
|
168
168
|
enable_cache: bool = True,
|
|
169
169
|
max_cost_per_day: float = 0.10,
|
|
@@ -230,15 +230,15 @@ class SubscriptionInsightsGenerator:
|
|
|
230
230
|
async def generate(
|
|
231
231
|
self,
|
|
232
232
|
subscriptions: list[dict[str, Any]],
|
|
233
|
-
user_id:
|
|
233
|
+
user_id: str | None = None,
|
|
234
234
|
) -> SubscriptionInsights:
|
|
235
235
|
"""
|
|
236
236
|
Generate subscription insights with natural language recommendations.
|
|
237
237
|
|
|
238
238
|
Flow:
|
|
239
|
-
1. Check cache (80% hit rate, key: insights:{user_id})
|
|
239
|
+
1. Check cache (80% hit rate, key: insights:{user_id}) -> <1ms
|
|
240
240
|
2. Check budget (daily/monthly caps)
|
|
241
|
-
3. Call LLM if cache miss
|
|
241
|
+
3. Call LLM if cache miss -> 300-500ms
|
|
242
242
|
4. Cache result (24h TTL)
|
|
243
243
|
5. Return SubscriptionInsights
|
|
244
244
|
|
|
@@ -291,8 +291,8 @@ class SubscriptionInsightsGenerator:
|
|
|
291
291
|
async def _get_cached(
|
|
292
292
|
self,
|
|
293
293
|
subscriptions: list[dict[str, Any]],
|
|
294
|
-
user_id:
|
|
295
|
-
) ->
|
|
294
|
+
user_id: str | None = None,
|
|
295
|
+
) -> SubscriptionInsights | None:
|
|
296
296
|
"""
|
|
297
297
|
Get cached insights.
|
|
298
298
|
|
|
@@ -317,7 +317,7 @@ class SubscriptionInsightsGenerator:
|
|
|
317
317
|
self,
|
|
318
318
|
subscriptions: list[dict[str, Any]],
|
|
319
319
|
result: SubscriptionInsights,
|
|
320
|
-
user_id:
|
|
320
|
+
user_id: str | None = None,
|
|
321
321
|
) -> None:
|
|
322
322
|
"""
|
|
323
323
|
Cache insights result.
|
|
@@ -338,7 +338,7 @@ class SubscriptionInsightsGenerator:
|
|
|
338
338
|
def _make_cache_key(
|
|
339
339
|
self,
|
|
340
340
|
subscriptions: list[dict[str, Any]],
|
|
341
|
-
user_id:
|
|
341
|
+
user_id: str | None = None,
|
|
342
342
|
) -> str:
|
|
343
343
|
"""
|
|
344
344
|
Generate cache key for insights.
|
|
@@ -352,6 +352,7 @@ class SubscriptionInsightsGenerator:
|
|
|
352
352
|
import json
|
|
353
353
|
|
|
354
354
|
subscriptions_json = json.dumps(subscriptions, sort_keys=True)
|
|
355
|
+
# Security: B324 skip justified - MD5 used for cache key generation only.
|
|
355
356
|
hash_hex = hashlib.md5(subscriptions_json.encode()).hexdigest()
|
|
356
357
|
return f"insights:{hash_hex}"
|
|
357
358
|
|
|
@@ -384,7 +385,7 @@ class SubscriptionInsightsGenerator:
|
|
|
384
385
|
|
|
385
386
|
# Extract structured output
|
|
386
387
|
if hasattr(response, "structured") and response.structured:
|
|
387
|
-
return cast(SubscriptionInsights, response.structured)
|
|
388
|
+
return cast("SubscriptionInsights", response.structured)
|
|
388
389
|
else:
|
|
389
390
|
raise ValueError("LLM returned no structured output for insights")
|
|
390
391
|
|
|
@@ -26,12 +26,12 @@ def normalize_merchant(raw_name: str) -> str:
|
|
|
26
26
|
Normalize merchant name for grouping.
|
|
27
27
|
|
|
28
28
|
Pipeline:
|
|
29
|
-
1. Lowercase: "NETFLIX.COM"
|
|
30
|
-
2. Remove domain suffixes: "netflix.com"
|
|
31
|
-
3. Remove special chars: "netflix*subscription"
|
|
32
|
-
4. Remove store/transaction numbers: "starbucks #12345"
|
|
33
|
-
5. Remove legal entities: "netflix inc"
|
|
34
|
-
6. Strip whitespace: " netflix "
|
|
29
|
+
1. Lowercase: "NETFLIX.COM" -> "netflix.com"
|
|
30
|
+
2. Remove domain suffixes: "netflix.com" -> "netflix"
|
|
31
|
+
3. Remove special chars: "netflix*subscription" -> "netflix subscription"
|
|
32
|
+
4. Remove store/transaction numbers: "starbucks #12345" -> "starbucks"
|
|
33
|
+
5. Remove legal entities: "netflix inc" -> "netflix"
|
|
34
|
+
6. Strip whitespace: " netflix " -> "netflix"
|
|
35
35
|
|
|
36
36
|
Args:
|
|
37
37
|
raw_name: Original merchant name
|
|
@@ -166,7 +166,7 @@ class FuzzyMatcher:
|
|
|
166
166
|
norm2 = normalize_merchant(name2)
|
|
167
167
|
|
|
168
168
|
similarity = fuzz.token_sort_ratio(norm1, norm2)
|
|
169
|
-
return cast(bool, similarity >= self.similarity_threshold)
|
|
169
|
+
return cast("bool", similarity >= self.similarity_threshold)
|
|
170
170
|
|
|
171
171
|
def group_merchants(self, merchants: list[str]) -> dict[str, list[str]]:
|
|
172
172
|
"""
|
|
@@ -8,7 +8,7 @@ Handles cryptic merchant names that fail pattern-based normalization:
|
|
|
8
8
|
- Legal entities: Inc, LLC, Corp, Ltd
|
|
9
9
|
|
|
10
10
|
Uses ai-infra LLM with few-shot prompting for 90-95% accuracy.
|
|
11
|
-
Caches results for 7 days (95% hit rate expected)
|
|
11
|
+
Caches results for 7 days (95% hit rate expected) -> <1ms latency.
|
|
12
12
|
Falls back to RapidFuzz if LLM fails or disabled.
|
|
13
13
|
"""
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import logging
|
|
19
|
-
from typing import Any,
|
|
19
|
+
from typing import Any, cast
|
|
20
20
|
|
|
21
21
|
from pydantic import BaseModel, ConfigDict, Field
|
|
22
22
|
|
|
@@ -93,26 +93,26 @@ Common patterns:
|
|
|
93
93
|
- POS systems: TST* (Toast), CLOVER*, SQUARE*
|
|
94
94
|
|
|
95
95
|
Examples:
|
|
96
|
-
1. "NFLX*SUB #12345"
|
|
97
|
-
2. "Netflix Inc"
|
|
98
|
-
3. "NETFLIX.COM"
|
|
99
|
-
4. "SQ *COZY CAFE"
|
|
100
|
-
5. "TST* STARBUCKS"
|
|
101
|
-
6. "AMZN MKTP US"
|
|
102
|
-
7. "SPFY*PREMIUM"
|
|
103
|
-
8. "UBER *TRIP 12345"
|
|
104
|
-
9. "LYFT *RIDE ABC"
|
|
105
|
-
10. "CLOVER* PIZZA PLACE"
|
|
106
|
-
11. "AAPL* ICLOUD STORAGE"
|
|
107
|
-
12. "MSFT*MICROSOFT 365"
|
|
108
|
-
13. "DISNEY PLUS #123"
|
|
109
|
-
14. "PRIME VIDEO"
|
|
110
|
-
15. "CITY ELECTRIC #456"
|
|
111
|
-
16. "T-MOBILE USA"
|
|
112
|
-
17. "VERIZON WIRELESS"
|
|
113
|
-
18. "WHOLE FOODS MKT #789"
|
|
114
|
-
19. "STARBUCKS #1234"
|
|
115
|
-
20. "LA FITNESS #567"
|
|
96
|
+
1. "NFLX*SUB #12345" -> Netflix (streaming)
|
|
97
|
+
2. "Netflix Inc" -> Netflix (streaming)
|
|
98
|
+
3. "NETFLIX.COM" -> Netflix (streaming)
|
|
99
|
+
4. "SQ *COZY CAFE" -> Cozy Cafe (coffee_shop, Square processor)
|
|
100
|
+
5. "TST* STARBUCKS" -> Starbucks (coffee_shop, Toast POS)
|
|
101
|
+
6. "AMZN MKTP US" -> Amazon (online_shopping)
|
|
102
|
+
7. "SPFY*PREMIUM" -> Spotify (streaming)
|
|
103
|
+
8. "UBER *TRIP 12345" -> Uber (rideshare)
|
|
104
|
+
9. "LYFT *RIDE ABC" -> Lyft (rideshare)
|
|
105
|
+
10. "CLOVER* PIZZA PLACE" -> Pizza Place (restaurant, Clover POS)
|
|
106
|
+
11. "AAPL* ICLOUD STORAGE" -> Apple iCloud (cloud_storage)
|
|
107
|
+
12. "MSFT*MICROSOFT 365" -> Microsoft 365 (software_subscription)
|
|
108
|
+
13. "DISNEY PLUS #123" -> Disney Plus (streaming)
|
|
109
|
+
14. "PRIME VIDEO" -> Amazon Prime Video (streaming)
|
|
110
|
+
15. "CITY ELECTRIC #456" -> City Electric (utility_electric)
|
|
111
|
+
16. "T-MOBILE USA" -> T-Mobile (phone_service)
|
|
112
|
+
17. "VERIZON WIRELESS" -> Verizon (phone_service)
|
|
113
|
+
18. "WHOLE FOODS MKT #789" -> Whole Foods (grocery)
|
|
114
|
+
19. "STARBUCKS #1234" -> Starbucks (coffee_shop)
|
|
115
|
+
20. "LA FITNESS #567" -> LA Fitness (gym)
|
|
116
116
|
|
|
117
117
|
Output format (JSON):
|
|
118
118
|
{
|
|
@@ -131,8 +131,8 @@ class MerchantNormalizer:
|
|
|
131
131
|
LLM-based merchant name normalizer with caching and fallback.
|
|
132
132
|
|
|
133
133
|
Layer 2 of 4-layer hybrid architecture:
|
|
134
|
-
1. Check cache first (95% hit rate, 7-day TTL)
|
|
135
|
-
2. Call LLM if cache miss
|
|
134
|
+
1. Check cache first (95% hit rate, 7-day TTL) -> <1ms
|
|
135
|
+
2. Call LLM if cache miss -> 200-400ms
|
|
136
136
|
3. Cache result for 7 days
|
|
137
137
|
4. Return MerchantNormalized
|
|
138
138
|
|
|
@@ -145,7 +145,7 @@ class MerchantNormalizer:
|
|
|
145
145
|
def __init__(
|
|
146
146
|
self,
|
|
147
147
|
provider: str = "google",
|
|
148
|
-
model_name:
|
|
148
|
+
model_name: str | None = None,
|
|
149
149
|
cache_ttl: int = 604800, # 7 days
|
|
150
150
|
enable_cache: bool = True,
|
|
151
151
|
confidence_threshold: float = 0.8,
|
|
@@ -221,9 +221,9 @@ class MerchantNormalizer:
|
|
|
221
221
|
Normalize merchant name using LLM with caching.
|
|
222
222
|
|
|
223
223
|
Flow:
|
|
224
|
-
1. Check cache (95% hit rate)
|
|
224
|
+
1. Check cache (95% hit rate) -> <1ms
|
|
225
225
|
2. Check budget (daily/monthly caps)
|
|
226
|
-
3. Call LLM if cache miss
|
|
226
|
+
3. Call LLM if cache miss -> 200-400ms
|
|
227
227
|
4. Validate confidence threshold
|
|
228
228
|
5. Cache result (7-day TTL)
|
|
229
229
|
6. Return MerchantNormalized
|
|
@@ -284,7 +284,7 @@ class MerchantNormalizer:
|
|
|
284
284
|
logger.error(f"LLM normalization failed for '{merchant_name}': {e}")
|
|
285
285
|
return self._fallback_normalize(merchant_name, fallback_confidence)
|
|
286
286
|
|
|
287
|
-
async def _get_cached(self, merchant_name: str) ->
|
|
287
|
+
async def _get_cached(self, merchant_name: str) -> MerchantNormalized | None:
|
|
288
288
|
"""
|
|
289
289
|
Get cached normalization result.
|
|
290
290
|
|
|
@@ -330,6 +330,7 @@ class MerchantNormalizer:
|
|
|
330
330
|
Format: merchant_norm:{md5_hex}
|
|
331
331
|
"""
|
|
332
332
|
normalized = merchant_name.lower().strip()
|
|
333
|
+
# Security: B324 skip justified - MD5 used for cache key generation only.
|
|
333
334
|
hash_hex = hashlib.md5(normalized.encode()).hexdigest()
|
|
334
335
|
return f"merchant_norm:{hash_hex}"
|
|
335
336
|
|
|
@@ -355,7 +356,7 @@ class MerchantNormalizer:
|
|
|
355
356
|
|
|
356
357
|
# Extract structured output
|
|
357
358
|
if hasattr(response, "structured") and response.structured:
|
|
358
|
-
return cast(MerchantNormalized, response.structured)
|
|
359
|
+
return cast("MerchantNormalized", response.structured)
|
|
359
360
|
else:
|
|
360
361
|
raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
|
|
361
362
|
|
|
@@ -386,7 +387,7 @@ class MerchantNormalizer:
|
|
|
386
387
|
# Remove special characters
|
|
387
388
|
normalized = normalized.replace("*", " ").replace("#", " ").replace(".", " ")
|
|
388
389
|
|
|
389
|
-
# Remove store numbers (e.g., "starbucks 1234"
|
|
390
|
+
# Remove store numbers (e.g., "starbucks 1234" -> "starbucks")
|
|
390
391
|
import re
|
|
391
392
|
|
|
392
393
|
normalized = re.sub(r"\b\d{3,}\b", "", normalized)
|
fin_infra/recurring/summary.py
CHANGED
|
@@ -33,14 +33,12 @@ Integration with svc-infra:
|
|
|
33
33
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
|
-
from typing import List, Dict, Optional
|
|
37
|
-
from datetime import datetime
|
|
38
36
|
from collections import defaultdict
|
|
37
|
+
from datetime import datetime
|
|
39
38
|
|
|
40
|
-
from pydantic import BaseModel,
|
|
41
|
-
|
|
42
|
-
from fin_infra.recurring.models import RecurringPattern, PatternType
|
|
39
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
43
40
|
|
|
41
|
+
from fin_infra.recurring.models import PatternType, RecurringPattern
|
|
44
42
|
|
|
45
43
|
__all__ = [
|
|
46
44
|
"RecurringItem",
|
|
@@ -121,16 +119,16 @@ class RecurringSummary(BaseModel):
|
|
|
121
119
|
user_id: str = Field(..., description="User identifier")
|
|
122
120
|
total_monthly_cost: float = Field(..., description="Total monthly recurring expenses")
|
|
123
121
|
total_monthly_income: float = Field(0.0, description="Total monthly recurring income")
|
|
124
|
-
subscriptions:
|
|
122
|
+
subscriptions: list[RecurringItem] = Field(
|
|
125
123
|
default_factory=list, description="List of recurring expense items"
|
|
126
124
|
)
|
|
127
|
-
recurring_income:
|
|
125
|
+
recurring_income: list[RecurringItem] = Field(
|
|
128
126
|
default_factory=list, description="List of recurring income items"
|
|
129
127
|
)
|
|
130
|
-
by_category:
|
|
128
|
+
by_category: dict[str, float] = Field(
|
|
131
129
|
default_factory=dict, description="Monthly cost grouped by category"
|
|
132
130
|
)
|
|
133
|
-
cancellation_opportunities:
|
|
131
|
+
cancellation_opportunities: list[CancellationOpportunity] = Field(
|
|
134
132
|
default_factory=list, description="Potential subscriptions to cancel"
|
|
135
133
|
)
|
|
136
134
|
generated_at: str = Field(
|
|
@@ -165,8 +163,8 @@ def _calculate_monthly_cost(amount: float, cadence: str) -> float:
|
|
|
165
163
|
|
|
166
164
|
|
|
167
165
|
def _identify_cancellation_opportunities(
|
|
168
|
-
subscriptions:
|
|
169
|
-
) ->
|
|
166
|
+
subscriptions: list[RecurringItem],
|
|
167
|
+
) -> list[CancellationOpportunity]:
|
|
170
168
|
"""Identify potential cancellation opportunities from subscriptions.
|
|
171
169
|
|
|
172
170
|
Looks for:
|
|
@@ -183,7 +181,7 @@ def _identify_cancellation_opportunities(
|
|
|
183
181
|
opportunities = []
|
|
184
182
|
|
|
185
183
|
# Group by category
|
|
186
|
-
by_category:
|
|
184
|
+
by_category: dict[str, list[RecurringItem]] = defaultdict(list)
|
|
187
185
|
for sub in subscriptions:
|
|
188
186
|
by_category[sub.category].append(sub)
|
|
189
187
|
|
|
@@ -253,8 +251,8 @@ def _identify_cancellation_opportunities(
|
|
|
253
251
|
|
|
254
252
|
def get_recurring_summary(
|
|
255
253
|
user_id: str,
|
|
256
|
-
patterns:
|
|
257
|
-
category_map:
|
|
254
|
+
patterns: list[RecurringPattern],
|
|
255
|
+
category_map: dict[str, str] | None = None,
|
|
258
256
|
) -> RecurringSummary:
|
|
259
257
|
"""Generate a comprehensive recurring transaction summary for a user.
|
|
260
258
|
|
|
@@ -283,7 +281,7 @@ def get_recurring_summary(
|
|
|
283
281
|
"""
|
|
284
282
|
subscriptions = []
|
|
285
283
|
recurring_income = []
|
|
286
|
-
by_category:
|
|
284
|
+
by_category: dict[str, float] = defaultdict(float)
|
|
287
285
|
|
|
288
286
|
for pattern in patterns:
|
|
289
287
|
# Determine amount (use fixed amount or average of range)
|
fin_infra/scaffold/budgets.py
CHANGED
|
@@ -19,10 +19,10 @@ Typical usage:
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Any
|
|
22
|
+
from typing import Any
|
|
23
23
|
|
|
24
24
|
# Use svc-infra's scaffold utilities to avoid duplication
|
|
25
|
-
from svc_infra.utils import render_template, write
|
|
25
|
+
from svc_infra.utils import ensure_init_py, render_template, write
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def scaffold_budgets_core(
|
|
@@ -31,10 +31,10 @@ def scaffold_budgets_core(
|
|
|
31
31
|
include_soft_delete: bool = False,
|
|
32
32
|
with_repository: bool = True,
|
|
33
33
|
overwrite: bool = False,
|
|
34
|
-
models_filename:
|
|
35
|
-
schemas_filename:
|
|
36
|
-
repository_filename:
|
|
37
|
-
) ->
|
|
34
|
+
models_filename: str | None = None,
|
|
35
|
+
schemas_filename: str | None = None,
|
|
36
|
+
repository_filename: str | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
38
|
"""Generate budget persistence code from templates.
|
|
39
39
|
|
|
40
40
|
Args:
|
|
@@ -72,7 +72,7 @@ def scaffold_budgets_core(
|
|
|
72
72
|
subs = _generate_substitutions(include_tenant, include_soft_delete)
|
|
73
73
|
|
|
74
74
|
# Track all file operations
|
|
75
|
-
files:
|
|
75
|
+
files: list[dict[str, Any]] = []
|
|
76
76
|
|
|
77
77
|
# Render and write models
|
|
78
78
|
models_content = render_template("fin_infra.budgets.scaffold_templates", "models.py.tmpl", subs)
|
|
@@ -114,7 +114,7 @@ def scaffold_budgets_core(
|
|
|
114
114
|
def _generate_substitutions(
|
|
115
115
|
include_tenant: bool,
|
|
116
116
|
include_soft_delete: bool,
|
|
117
|
-
) ->
|
|
117
|
+
) -> dict[str, str]:
|
|
118
118
|
"""Generate template variable substitutions for budgets.
|
|
119
119
|
|
|
120
120
|
Args:
|
|
@@ -229,7 +229,7 @@ def _tenant_field_schema_read() -> str:
|
|
|
229
229
|
def _generate_init_content(
|
|
230
230
|
models_file: str,
|
|
231
231
|
schemas_file: str,
|
|
232
|
-
repo_file:
|
|
232
|
+
repo_file: str | None,
|
|
233
233
|
) -> str:
|
|
234
234
|
"""Generate __init__.py content with re-exports.
|
|
235
235
|
|
fin_infra/scaffold/goals.py
CHANGED
|
@@ -17,19 +17,19 @@ Typical usage:
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any
|
|
20
|
+
from typing import Any
|
|
21
21
|
|
|
22
22
|
from svc_infra.utils import (
|
|
23
|
+
ensure_init_py,
|
|
23
24
|
render_template,
|
|
24
25
|
write,
|
|
25
|
-
ensure_init_py,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _generate_substitutions(
|
|
30
30
|
include_tenant: bool = False,
|
|
31
31
|
include_soft_delete: bool = False,
|
|
32
|
-
) ->
|
|
32
|
+
) -> dict[str, str]:
|
|
33
33
|
"""
|
|
34
34
|
Generate template substitutions for goals domain.
|
|
35
35
|
|
|
@@ -49,7 +49,7 @@ def _generate_substitutions(
|
|
|
49
49
|
Returns:
|
|
50
50
|
Dict mapping variable names to their substitution values
|
|
51
51
|
"""
|
|
52
|
-
subs:
|
|
52
|
+
subs: dict[str, str] = {
|
|
53
53
|
"Entity": "Goal",
|
|
54
54
|
"entity": "goal",
|
|
55
55
|
"table_name": "goals",
|
|
@@ -173,15 +173,15 @@ def scaffold_goals_core(
|
|
|
173
173
|
models_filename: str = "goal.py",
|
|
174
174
|
schemas_filename: str = "goal_schemas.py",
|
|
175
175
|
repository_filename: str = "goal_repository.py",
|
|
176
|
-
) ->
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
177
|
"""
|
|
178
178
|
Scaffold goals domain files: models, schemas, repository (optional), and __init__.py.
|
|
179
179
|
|
|
180
180
|
Generates production-ready code from templates in fin_infra.goals.scaffold_templates:
|
|
181
|
-
- models.py.tmpl
|
|
182
|
-
- schemas.py.tmpl
|
|
183
|
-
- repository.py.tmpl
|
|
184
|
-
- README.md
|
|
181
|
+
- models.py.tmpl -> Goal model with progress tracking, status, priority, milestones
|
|
182
|
+
- schemas.py.tmpl -> GoalBase, GoalCreate, GoalUpdate, GoalRead with status validation
|
|
183
|
+
- repository.py.tmpl -> GoalRepository with CRUD + domain methods (get_active, update_progress)
|
|
184
|
+
- README.md -> Complete usage guide with examples
|
|
185
185
|
|
|
186
186
|
Args:
|
|
187
187
|
dest_dir: Destination directory (will be created if missing)
|
fin_infra/security/__init__.py
CHANGED
|
@@ -8,25 +8,25 @@ Provides:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from .add import add_financial_security, generate_encryption_key
|
|
11
|
-
from .audit import
|
|
11
|
+
from .audit import clear_audit_logs, get_audit_logs, log_pii_access
|
|
12
12
|
from .encryption import ProviderTokenEncryption
|
|
13
|
-
from .models import
|
|
13
|
+
from .models import PIIAccessLog, ProviderTokenMetadata
|
|
14
14
|
from .pii_filter import FinancialPIIFilter
|
|
15
15
|
from .pii_patterns import (
|
|
16
|
-
SSN_PATTERN,
|
|
17
16
|
ACCOUNT_PATTERN,
|
|
18
|
-
ROUTING_PATTERN,
|
|
19
17
|
CARD_PATTERN,
|
|
20
18
|
CVV_PATTERN,
|
|
21
19
|
EIN_PATTERN,
|
|
22
|
-
|
|
20
|
+
ROUTING_PATTERN,
|
|
21
|
+
SSN_PATTERN,
|
|
23
22
|
is_valid_routing_number,
|
|
23
|
+
luhn_checksum,
|
|
24
24
|
)
|
|
25
25
|
from .token_store import (
|
|
26
|
-
store_provider_token,
|
|
27
|
-
get_provider_token,
|
|
28
|
-
delete_provider_token,
|
|
29
26
|
ProviderToken,
|
|
27
|
+
delete_provider_token,
|
|
28
|
+
get_provider_token,
|
|
29
|
+
store_provider_token,
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
__all__ = [
|
fin_infra/security/add.py
CHANGED
|
@@ -5,7 +5,6 @@ Easy integration of financial PII masking and token encryption.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
from typing import Optional
|
|
9
8
|
|
|
10
9
|
from fastapi import FastAPI
|
|
11
10
|
|
|
@@ -15,7 +14,7 @@ from .pii_filter import FinancialPIIFilter
|
|
|
15
14
|
|
|
16
15
|
def add_financial_security(
|
|
17
16
|
app: FastAPI,
|
|
18
|
-
encryption_key:
|
|
17
|
+
encryption_key: bytes | None = None,
|
|
19
18
|
enable_pii_filter: bool = True,
|
|
20
19
|
enable_audit_log: bool = True,
|
|
21
20
|
mask_emails: bool = False,
|
fin_infra/security/audit.py
CHANGED
|
@@ -6,7 +6,6 @@ Track access to sensitive financial data for compliance.
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
from datetime import datetime
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from .models import PIIAccessLog
|
|
12
11
|
|
|
@@ -23,10 +22,10 @@ async def log_pii_access(
|
|
|
23
22
|
pii_type: str,
|
|
24
23
|
action: str,
|
|
25
24
|
resource: str,
|
|
26
|
-
ip_address:
|
|
27
|
-
user_agent:
|
|
25
|
+
ip_address: str | None = None,
|
|
26
|
+
user_agent: str | None = None,
|
|
28
27
|
success: bool = True,
|
|
29
|
-
error_message:
|
|
28
|
+
error_message: str | None = None,
|
|
30
29
|
) -> PIIAccessLog:
|
|
31
30
|
"""
|
|
32
31
|
Log PII access for audit trail.
|
|
@@ -86,9 +85,9 @@ async def log_pii_access(
|
|
|
86
85
|
|
|
87
86
|
|
|
88
87
|
def get_audit_logs(
|
|
89
|
-
user_id:
|
|
90
|
-
pii_type:
|
|
91
|
-
action:
|
|
88
|
+
user_id: str | None = None,
|
|
89
|
+
pii_type: str | None = None,
|
|
90
|
+
action: str | None = None,
|
|
92
91
|
limit: int = 100,
|
|
93
92
|
) -> list[PIIAccessLog]:
|
|
94
93
|
"""
|
fin_infra/security/encryption.py
CHANGED
|
@@ -7,7 +7,7 @@ Encrypt/decrypt financial provider API tokens at rest.
|
|
|
7
7
|
import base64
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
-
from typing import Any,
|
|
10
|
+
from typing import Any, cast
|
|
11
11
|
|
|
12
12
|
from cryptography.fernet import Fernet, InvalidToken
|
|
13
13
|
|
|
@@ -37,7 +37,7 @@ class ProviderTokenEncryption:
|
|
|
37
37
|
>>> token = encryption.decrypt(encrypted, context={"user_id": "user123", "provider": "plaid"})
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
|
-
def __init__(self, key:
|
|
40
|
+
def __init__(self, key: bytes | None = None):
|
|
41
41
|
"""
|
|
42
42
|
Initialize token encryption.
|
|
43
43
|
|
|
@@ -64,7 +64,7 @@ class ProviderTokenEncryption:
|
|
|
64
64
|
raise ValueError(f"Invalid encryption key: {e}") from e
|
|
65
65
|
|
|
66
66
|
def encrypt(
|
|
67
|
-
self, token: str, context:
|
|
67
|
+
self, token: str, context: dict[str, Any] | None = None, key_id: str | None = None
|
|
68
68
|
) -> str:
|
|
69
69
|
"""
|
|
70
70
|
Encrypt provider token with optional context.
|
|
@@ -104,7 +104,7 @@ class ProviderTokenEncryption:
|
|
|
104
104
|
def decrypt(
|
|
105
105
|
self,
|
|
106
106
|
encrypted_token: str,
|
|
107
|
-
context:
|
|
107
|
+
context: dict[str, Any] | None = None,
|
|
108
108
|
verify_context: bool = True,
|
|
109
109
|
) -> str:
|
|
110
110
|
"""
|
|
@@ -144,7 +144,7 @@ class ProviderTokenEncryption:
|
|
|
144
144
|
"Token may have been tampered with or used for wrong user/provider."
|
|
145
145
|
)
|
|
146
146
|
|
|
147
|
-
return cast(str, data["token"])
|
|
147
|
+
return cast("str", data["token"])
|
|
148
148
|
|
|
149
149
|
except InvalidToken as e:
|
|
150
150
|
raise ValueError(
|
|
@@ -154,7 +154,7 @@ class ProviderTokenEncryption:
|
|
|
154
154
|
raise ValueError(f"Decryption failed: {e}") from e
|
|
155
155
|
|
|
156
156
|
def rotate_key(
|
|
157
|
-
self, encrypted_token: str, new_key: bytes, context:
|
|
157
|
+
self, encrypted_token: str, new_key: bytes, context: dict[str, Any] | None = None
|
|
158
158
|
) -> str:
|
|
159
159
|
"""
|
|
160
160
|
Re-encrypt token with new key (for key rotation).
|
fin_infra/security/models.py
CHANGED
|
@@ -5,7 +5,7 @@ Pydantic models for security-related operations.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from datetime import datetime
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
11
|
|
|
@@ -15,12 +15,12 @@ class ProviderTokenMetadata(BaseModel):
|
|
|
15
15
|
user_id: str = Field(..., description="User ID who owns the token")
|
|
16
16
|
provider: str = Field(..., description="Provider name (plaid, alpaca, alphavantage, etc.)")
|
|
17
17
|
encrypted_token: str = Field(..., description="Encrypted token (base64-encoded)")
|
|
18
|
-
key_id:
|
|
18
|
+
key_id: str | None = Field(None, description="Key ID for key rotation")
|
|
19
19
|
created_at: datetime = Field(
|
|
20
20
|
default_factory=datetime.utcnow, description="Token creation timestamp"
|
|
21
21
|
)
|
|
22
|
-
expires_at:
|
|
23
|
-
last_used_at:
|
|
22
|
+
expires_at: datetime | None = Field(None, description="Token expiration timestamp")
|
|
23
|
+
last_used_at: datetime | None = Field(None, description="Last time token was used")
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class PIIAccessLog(BaseModel):
|
|
@@ -30,8 +30,8 @@ class PIIAccessLog(BaseModel):
|
|
|
30
30
|
pii_type: str = Field(..., description="Type of PII (ssn, account, card, etc.)")
|
|
31
31
|
action: str = Field(..., description="Action performed (read, write, delete)")
|
|
32
32
|
resource: str = Field(..., description="Resource accessed (e.g., user:123, account:456)")
|
|
33
|
-
ip_address:
|
|
34
|
-
user_agent:
|
|
33
|
+
ip_address: str | None = Field(None, description="IP address of requester")
|
|
34
|
+
user_agent: str | None = Field(None, description="User agent string")
|
|
35
35
|
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Access timestamp")
|
|
36
36
|
success: bool = Field(True, description="Whether access was successful")
|
|
37
|
-
error_message:
|
|
37
|
+
error_message: str | None = Field(None, description="Error message if failed")
|