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.
Files changed (131) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +24 -24
  4. fin_infra/analytics/cash_flow.py +3 -3
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/models.py +5 -5
  7. fin_infra/analytics/portfolio.py +18 -18
  8. fin_infra/analytics/projections.py +1 -3
  9. fin_infra/analytics/spending.py +4 -5
  10. fin_infra/banking/__init__.py +27 -28
  11. fin_infra/banking/history.py +12 -13
  12. fin_infra/banking/utils.py +27 -26
  13. fin_infra/brokerage/__init__.py +29 -31
  14. fin_infra/budgets/__init__.py +3 -3
  15. fin_infra/budgets/add.py +16 -17
  16. fin_infra/budgets/alerts.py +4 -4
  17. fin_infra/budgets/ease.py +1 -2
  18. fin_infra/budgets/models.py +1 -2
  19. fin_infra/budgets/templates.py +4 -4
  20. fin_infra/budgets/tracker.py +4 -4
  21. fin_infra/cashflows/__init__.py +3 -3
  22. fin_infra/cashflows/core.py +1 -1
  23. fin_infra/categorization/__init__.py +1 -1
  24. fin_infra/categorization/add.py +2 -3
  25. fin_infra/categorization/ease.py +3 -3
  26. fin_infra/categorization/engine.py +18 -15
  27. fin_infra/categorization/llm_layer.py +13 -10
  28. fin_infra/categorization/models.py +3 -4
  29. fin_infra/categorization/rules.py +2 -4
  30. fin_infra/categorization/taxonomy.py +2 -2
  31. fin_infra/chat/__init__.py +6 -6
  32. fin_infra/chat/planning.py +1 -2
  33. fin_infra/cli/cmds/scaffold_cmds.py +16 -17
  34. fin_infra/clients/__init__.py +23 -1
  35. fin_infra/clients/base.py +1 -1
  36. fin_infra/clients/plaid.py +2 -2
  37. fin_infra/compliance/__init__.py +5 -4
  38. fin_infra/credit/add.py +6 -7
  39. fin_infra/credit/experian/auth.py +2 -2
  40. fin_infra/credit/experian/client.py +1 -1
  41. fin_infra/credit/experian/parser.py +5 -5
  42. fin_infra/credit/experian/provider.py +4 -4
  43. fin_infra/crypto/__init__.py +9 -11
  44. fin_infra/crypto/insights.py +4 -3
  45. fin_infra/documents/add.py +6 -8
  46. fin_infra/documents/analysis.py +9 -9
  47. fin_infra/documents/ease.py +14 -14
  48. fin_infra/documents/models.py +5 -6
  49. fin_infra/documents/ocr.py +7 -7
  50. fin_infra/documents/storage.py +21 -13
  51. fin_infra/exceptions.py +0 -1
  52. fin_infra/goals/__init__.py +8 -8
  53. fin_infra/goals/add.py +36 -36
  54. fin_infra/goals/funding.py +4 -6
  55. fin_infra/goals/management.py +5 -6
  56. fin_infra/goals/milestones.py +7 -8
  57. fin_infra/goals/models.py +9 -13
  58. fin_infra/insights/__init__.py +6 -3
  59. fin_infra/insights/aggregator.py +1 -1
  60. fin_infra/investments/__init__.py +3 -3
  61. fin_infra/investments/add.py +23 -23
  62. fin_infra/investments/ease.py +2 -2
  63. fin_infra/investments/models.py +27 -29
  64. fin_infra/investments/providers/base.py +12 -13
  65. fin_infra/investments/providers/plaid.py +52 -26
  66. fin_infra/investments/providers/snaptrade.py +19 -19
  67. fin_infra/investments/scaffold_templates/README.md +17 -17
  68. fin_infra/markets/__init__.py +7 -5
  69. fin_infra/models/__init__.py +10 -10
  70. fin_infra/models/accounts.py +4 -5
  71. fin_infra/models/brokerage.py +2 -1
  72. fin_infra/models/candle.py +1 -0
  73. fin_infra/models/money.py +1 -0
  74. fin_infra/models/quotes.py +4 -3
  75. fin_infra/models/tax.py +2 -1
  76. fin_infra/models/transactions.py +4 -5
  77. fin_infra/net_worth/__init__.py +8 -1
  78. fin_infra/net_worth/aggregator.py +5 -3
  79. fin_infra/net_worth/calculator.py +1 -1
  80. fin_infra/net_worth/insights.py +7 -8
  81. fin_infra/normalization/__init__.py +4 -4
  82. fin_infra/normalization/currency_converter.py +7 -8
  83. fin_infra/normalization/models.py +9 -10
  84. fin_infra/normalization/providers/exchangerate.py +5 -5
  85. fin_infra/normalization/providers/static_mappings.py +1 -1
  86. fin_infra/normalization/symbol_resolver.py +3 -4
  87. fin_infra/obs/classifier.py +3 -3
  88. fin_infra/providers/banking/plaid_client.py +5 -5
  89. fin_infra/providers/banking/teller_client.py +7 -6
  90. fin_infra/providers/base.py +27 -2
  91. fin_infra/providers/brokerage/alpaca.py +4 -4
  92. fin_infra/providers/market/alphavantage.py +6 -11
  93. fin_infra/providers/market/ccxt_crypto.py +19 -3
  94. fin_infra/providers/market/coingecko.py +5 -6
  95. fin_infra/providers/market/yahoo.py +23 -8
  96. fin_infra/providers/tax/__init__.py +1 -1
  97. fin_infra/providers/tax/irs.py +1 -1
  98. fin_infra/providers/tax/mock.py +5 -5
  99. fin_infra/providers/tax/taxbit.py +1 -1
  100. fin_infra/recurring/__init__.py +6 -6
  101. fin_infra/recurring/add.py +6 -5
  102. fin_infra/recurring/detector.py +7 -7
  103. fin_infra/recurring/detectors_llm.py +10 -10
  104. fin_infra/recurring/ease.py +6 -8
  105. fin_infra/recurring/insights.py +25 -24
  106. fin_infra/recurring/normalizer.py +7 -7
  107. fin_infra/recurring/normalizers.py +31 -30
  108. fin_infra/recurring/summary.py +13 -15
  109. fin_infra/scaffold/budgets.py +9 -9
  110. fin_infra/scaffold/goals.py +9 -9
  111. fin_infra/security/__init__.py +8 -8
  112. fin_infra/security/add.py +1 -2
  113. fin_infra/security/audit.py +6 -7
  114. fin_infra/security/encryption.py +6 -6
  115. fin_infra/security/models.py +7 -7
  116. fin_infra/security/pii_filter.py +16 -16
  117. fin_infra/security/token_store.py +2 -3
  118. fin_infra/settings.py +2 -1
  119. fin_infra/tax/__init__.py +1 -1
  120. fin_infra/tax/add.py +5 -4
  121. fin_infra/tax/tlh.py +10 -10
  122. fin_infra/utils/__init__.py +15 -1
  123. fin_infra/utils/deprecation.py +161 -0
  124. fin_infra/utils/http.py +4 -3
  125. fin_infra/utils/retry.py +2 -1
  126. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
  127. fin_infra-0.4.0.dist-info/RECORD +181 -0
  128. fin_infra-0.1.69.dist-info/RECORD +0 -180
  129. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
  130. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
  131. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -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) <1ms latency.
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, Optional, cast
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: Optional[float] = Field(
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
- "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
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
- total_monthly_cost: 64.95
112
- potential_savings: 30.00
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
- "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
115
+ -> "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
116
116
  to save $10.99/month."
117
- total_monthly_cost: 20.98
118
- potential_savings: 10.99
117
+ -> total_monthly_cost: 20.98
118
+ -> potential_savings: 10.99
119
119
 
120
120
  3. Subscriptions: LA Fitness $40, Planet Fitness $10
121
- "You have 2 gym memberships totaling $50/month. Consider consolidating to
121
+ -> "You have 2 gym memberships totaling $50/month. Consider consolidating to
122
122
  just Planet Fitness to save $40/month."
123
- total_monthly_cost: 50.00
124
- potential_savings: 40.00
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) <1ms
156
- 2. Call LLM if cache miss 300-500ms
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: Optional[str] = None,
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: Optional[str] = None,
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}) <1ms
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 300-500ms
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: Optional[str] = None,
295
- ) -> Optional[SubscriptionInsights]:
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: Optional[str] = None,
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: Optional[str] = None,
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" "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"
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) <1ms latency.
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, Optional, cast
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" 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)
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) <1ms
135
- 2. Call LLM if cache miss 200-400ms
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: Optional[str] = None,
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) <1ms
224
+ 1. Check cache (95% hit rate) -> <1ms
225
225
  2. Check budget (daily/monthly caps)
226
- 3. Call LLM if cache miss 200-400ms
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) -> Optional[MerchantNormalized]:
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" "starbucks")
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)
@@ -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, Field, ConfigDict
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: List[RecurringItem] = Field(
122
+ subscriptions: list[RecurringItem] = Field(
125
123
  default_factory=list, description="List of recurring expense items"
126
124
  )
127
- recurring_income: List[RecurringItem] = Field(
125
+ recurring_income: list[RecurringItem] = Field(
128
126
  default_factory=list, description="List of recurring income items"
129
127
  )
130
- by_category: Dict[str, float] = Field(
128
+ by_category: dict[str, float] = Field(
131
129
  default_factory=dict, description="Monthly cost grouped by category"
132
130
  )
133
- cancellation_opportunities: List[CancellationOpportunity] = Field(
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: List[RecurringItem],
169
- ) -> List[CancellationOpportunity]:
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: Dict[str, List[RecurringItem]] = defaultdict(list)
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: List[RecurringPattern],
257
- category_map: Optional[Dict[str, str]] = None,
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: Dict[str, float] = defaultdict(float)
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)
@@ -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, Dict, List, Optional
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, ensure_init_py
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: Optional[str] = None,
35
- schemas_filename: Optional[str] = None,
36
- repository_filename: Optional[str] = None,
37
- ) -> Dict[str, Any]:
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: List[Dict[str, Any]] = []
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
- ) -> Dict[str, str]:
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: Optional[str],
232
+ repo_file: str | None,
233
233
  ) -> str:
234
234
  """Generate __init__.py content with re-exports.
235
235
 
@@ -17,19 +17,19 @@ Typical usage:
17
17
  """
18
18
 
19
19
  from pathlib import Path
20
- from typing import Any, Dict
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
- ) -> Dict[str, str]:
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: Dict[str, str] = {
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
- ) -> Dict[str, Any]:
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 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
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)
@@ -8,25 +8,25 @@ Provides:
8
8
  """
9
9
 
10
10
  from .add import add_financial_security, generate_encryption_key
11
- from .audit import log_pii_access, get_audit_logs, clear_audit_logs
11
+ from .audit import clear_audit_logs, get_audit_logs, log_pii_access
12
12
  from .encryption import ProviderTokenEncryption
13
- from .models import ProviderTokenMetadata, PIIAccessLog
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
- luhn_checksum,
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: Optional[bytes] = None,
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,
@@ -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: Optional[str] = None,
27
- user_agent: Optional[str] = None,
25
+ ip_address: str | None = None,
26
+ user_agent: str | None = None,
28
27
  success: bool = True,
29
- error_message: Optional[str] = None,
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: Optional[str] = None,
90
- pii_type: Optional[str] = None,
91
- action: Optional[str] = None,
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
  """
@@ -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, Dict, Optional, cast
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: Optional[bytes] = None):
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: Optional[Dict[str, Any]] = None, key_id: Optional[str] = None
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: Optional[Dict[str, Any]] = None,
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: Optional[Dict[str, Any]] = None
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).
@@ -5,7 +5,7 @@ Pydantic models for security-related operations.
5
5
  """
6
6
 
7
7
  from datetime import datetime
8
- from typing import Optional
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: Optional[str] = Field(None, description="Key ID for key rotation")
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: Optional[datetime] = Field(None, description="Token expiration timestamp")
23
- last_used_at: Optional[datetime] = Field(None, description="Last time token was used")
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: Optional[str] = Field(None, description="IP address of requester")
34
- user_agent: Optional[str] = Field(None, description="User agent string")
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: Optional[str] = Field(None, description="Error message if failed")
37
+ error_message: str | None = Field(None, description="Error message if failed")