fin-infra 0.1.57__py3-none-any.whl → 0.1.59__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 +11 -0
- fin_infra/analytics/spending.py +8 -7
- fin_infra/categorization/llm_layer.py +1 -1
- fin_infra/credit/experian/auth.py +9 -6
- fin_infra/credit/experian/client.py +14 -26
- fin_infra/credit/experian/provider.py +132 -14
- fin_infra/exceptions.py +613 -0
- fin_infra/investments/add.py +46 -21
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/providers/base.py +2 -1
- fin_infra/net_worth/calculator.py +32 -6
- fin_infra/normalization/currency_converter.py +8 -10
- fin_infra/normalization/providers/exchangerate.py +6 -5
- fin_infra/normalization/symbol_resolver.py +7 -6
- fin_infra/providers/banking/plaid_client.py +10 -2
- fin_infra/providers/base.py +23 -3
- fin_infra/providers/market/coingecko.py +6 -2
- fin_infra/providers/registry.py +11 -6
- fin_infra/settings.py +1 -1
- fin_infra/utils/__init__.py +6 -1
- fin_infra/utils/retry.py +4 -3
- {fin_infra-0.1.57.dist-info → fin_infra-0.1.59.dist-info}/METADATA +1 -1
- {fin_infra-0.1.57.dist-info → fin_infra-0.1.59.dist-info}/RECORD +26 -26
- fin_infra/utils.py +0 -36
- {fin_infra-0.1.57.dist-info → fin_infra-0.1.59.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.57.dist-info → fin_infra-0.1.59.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.57.dist-info → fin_infra-0.1.59.dist-info}/entry_points.txt +0 -0
fin_infra/__init__.py
CHANGED
|
@@ -4,8 +4,19 @@ Public surface is intentionally small at this stage. Import from submodules for
|
|
|
4
4
|
specific domains (clients, models, markets, credit).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
FinInfraError,
|
|
9
|
+
ProviderError,
|
|
10
|
+
ProviderNotFoundError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
7
13
|
from .version import __version__
|
|
8
14
|
|
|
9
15
|
__all__ = [
|
|
10
16
|
"__version__",
|
|
17
|
+
# Base errors
|
|
18
|
+
"FinInfraError",
|
|
19
|
+
"ProviderError",
|
|
20
|
+
"ProviderNotFoundError",
|
|
21
|
+
"ValidationError",
|
|
11
22
|
]
|
fin_infra/analytics/spending.py
CHANGED
|
@@ -44,6 +44,7 @@ Examples:
|
|
|
44
44
|
|
|
45
45
|
from collections import defaultdict
|
|
46
46
|
from datetime import timedelta
|
|
47
|
+
from decimal import Decimal
|
|
47
48
|
from typing import Optional
|
|
48
49
|
|
|
49
50
|
from fin_infra.analytics.models import (
|
|
@@ -132,7 +133,7 @@ async def analyze_spending(
|
|
|
132
133
|
]
|
|
133
134
|
|
|
134
135
|
# Calculate top merchants
|
|
135
|
-
merchant_totals: dict[str,
|
|
136
|
+
merchant_totals: dict[str, Decimal] = defaultdict(Decimal)
|
|
136
137
|
for t in expense_transactions:
|
|
137
138
|
merchant = _extract_merchant_name(t.description or "Unknown")
|
|
138
139
|
merchant_totals[merchant] += abs(t.amount)
|
|
@@ -142,7 +143,7 @@ async def analyze_spending(
|
|
|
142
143
|
] # Top 10 merchants
|
|
143
144
|
|
|
144
145
|
# Calculate category breakdown
|
|
145
|
-
category_totals: dict[str,
|
|
146
|
+
category_totals: dict[str, Decimal] = defaultdict(Decimal)
|
|
146
147
|
for t in expense_transactions:
|
|
147
148
|
category = _get_transaction_category(t)
|
|
148
149
|
category_totals[category] += abs(t.amount)
|
|
@@ -261,7 +262,7 @@ def _get_transaction_category(transaction: Transaction) -> str:
|
|
|
261
262
|
|
|
262
263
|
async def _calculate_spending_trends(
|
|
263
264
|
user_id: str,
|
|
264
|
-
current_category_totals: dict[str,
|
|
265
|
+
current_category_totals: dict[str, Decimal],
|
|
265
266
|
current_period_days: int,
|
|
266
267
|
banking_provider=None,
|
|
267
268
|
categorization_provider=None,
|
|
@@ -288,7 +289,7 @@ async def _calculate_spending_trends(
|
|
|
288
289
|
for category, current_amount in current_category_totals.items():
|
|
289
290
|
# Mock: assume previous period was 10% lower on average
|
|
290
291
|
# In reality, would fetch historical data
|
|
291
|
-
previous_amount = current_amount * 0.9
|
|
292
|
+
previous_amount = current_amount * Decimal("0.9")
|
|
292
293
|
|
|
293
294
|
change_percent = (
|
|
294
295
|
((current_amount - previous_amount) / previous_amount) * 100
|
|
@@ -311,7 +312,7 @@ async def _calculate_spending_trends(
|
|
|
311
312
|
|
|
312
313
|
async def _detect_spending_anomalies(
|
|
313
314
|
user_id: str,
|
|
314
|
-
current_category_totals: dict[str,
|
|
315
|
+
current_category_totals: dict[str, Decimal],
|
|
315
316
|
current_period_days: int,
|
|
316
317
|
banking_provider=None,
|
|
317
318
|
categorization_provider=None,
|
|
@@ -339,7 +340,7 @@ async def _detect_spending_anomalies(
|
|
|
339
340
|
for category, current_amount in current_category_totals.items():
|
|
340
341
|
# Mock: assume historical average is current amount * 0.8
|
|
341
342
|
# In reality, would calculate from historical data
|
|
342
|
-
average_amount = current_amount * 0.8
|
|
343
|
+
average_amount = current_amount * Decimal("0.8")
|
|
343
344
|
|
|
344
345
|
deviation_percent = (
|
|
345
346
|
((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0
|
|
@@ -409,7 +410,7 @@ def _generate_mock_transactions(days: int) -> list[Transaction]:
|
|
|
409
410
|
Transaction(
|
|
410
411
|
id=f"mock_{i}",
|
|
411
412
|
account_id="mock_account",
|
|
412
|
-
amount=
|
|
413
|
+
amount=Decimal(str(amount)),
|
|
413
414
|
date=base_date - timedelta(days=days_ago),
|
|
414
415
|
description=description,
|
|
415
416
|
)
|
|
@@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
|
|
|
20
20
|
|
|
21
21
|
# ai-infra imports
|
|
22
22
|
try:
|
|
23
|
-
from ai_infra.llm import LLM
|
|
23
|
+
from ai_infra.llm import CoreLLM as LLM
|
|
24
24
|
from ai_infra.llm.providers import Providers
|
|
25
25
|
except ImportError:
|
|
26
26
|
raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
|
|
@@ -27,7 +27,6 @@ import base64
|
|
|
27
27
|
|
|
28
28
|
import httpx
|
|
29
29
|
from svc_infra.cache import cache_read
|
|
30
|
-
from svc_infra.cache.tags import invalidate_tags
|
|
31
30
|
|
|
32
31
|
# Cache key for OAuth tokens: "oauth_token:experian:{base_url_hash}"
|
|
33
32
|
# TTL: 3600 seconds (1 hour) - matches typical OAuth token expiry
|
|
@@ -91,7 +90,7 @@ class ExperianAuthManager:
|
|
|
91
90
|
@cache_read(
|
|
92
91
|
key="oauth_token:experian:{client_id}", # Use client_id for uniqueness
|
|
93
92
|
ttl=3600, # 1 hour - matches OAuth token expiry
|
|
94
|
-
tags=lambda **kw: ["oauth:experian"],
|
|
93
|
+
tags=lambda **kw: [f"oauth:experian:{kw['client_id']}"], # Client-specific tag
|
|
95
94
|
)
|
|
96
95
|
async def _get_token_cached(self, *, client_id: str) -> str:
|
|
97
96
|
"""Cached token getter (internal method).
|
|
@@ -144,10 +143,10 @@ class ExperianAuthManager:
|
|
|
144
143
|
return data["access_token"]
|
|
145
144
|
|
|
146
145
|
async def invalidate(self) -> None:
|
|
147
|
-
"""Invalidate cached token (force refresh on next get_token call).
|
|
146
|
+
"""Invalidate cached token for THIS client (force refresh on next get_token call).
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
Invalidates only the token for this specific client_id, not all Experian tokens.
|
|
149
|
+
Useful when token is rejected by API.
|
|
151
150
|
|
|
152
151
|
Example:
|
|
153
152
|
>>> try:
|
|
@@ -157,4 +156,8 @@ class ExperianAuthManager:
|
|
|
157
156
|
... await auth.invalidate()
|
|
158
157
|
... # Next get_token() will fetch new token
|
|
159
158
|
"""
|
|
160
|
-
|
|
159
|
+
# Import here to avoid circular import
|
|
160
|
+
from svc_infra.cache.tags import invalidate_tags
|
|
161
|
+
|
|
162
|
+
# Invalidate using client-specific tag, not all Experian tokens
|
|
163
|
+
await invalidate_tags(f"oauth:experian:{self.client_id}")
|
|
@@ -25,33 +25,21 @@ from tenacity import (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
from fin_infra.credit.experian.auth import ExperianAuthManager
|
|
28
|
+
from fin_infra.exceptions import (
|
|
29
|
+
ExperianAPIError,
|
|
30
|
+
ExperianAuthError,
|
|
31
|
+
ExperianNotFoundError,
|
|
32
|
+
ExperianRateLimitError,
|
|
33
|
+
)
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class ExperianRateLimitError(ExperianAPIError):
|
|
40
|
-
"""Raised when rate limit is exceeded (429)."""
|
|
41
|
-
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class ExperianAuthError(ExperianAPIError):
|
|
46
|
-
"""Raised when authentication fails (401)."""
|
|
47
|
-
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class ExperianNotFoundError(ExperianAPIError):
|
|
52
|
-
"""Raised when user not found in bureau (404)."""
|
|
53
|
-
|
|
54
|
-
pass
|
|
35
|
+
# Re-export for backward compatibility
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ExperianAPIError",
|
|
38
|
+
"ExperianAuthError",
|
|
39
|
+
"ExperianNotFoundError",
|
|
40
|
+
"ExperianRateLimitError",
|
|
41
|
+
"ExperianClient",
|
|
42
|
+
]
|
|
55
43
|
|
|
56
44
|
|
|
57
45
|
class ExperianClient:
|
|
@@ -7,6 +7,7 @@ Features:
|
|
|
7
7
|
- HTTP client with retry logic
|
|
8
8
|
- Response parsing to Pydantic models
|
|
9
9
|
- FCRA compliance headers
|
|
10
|
+
- FCRA audit logging (required for regulatory compliance)
|
|
10
11
|
- Error handling
|
|
11
12
|
|
|
12
13
|
Example:
|
|
@@ -28,6 +29,8 @@ Example:
|
|
|
28
29
|
>>> print(len(report.accounts)) # Real credit accounts
|
|
29
30
|
"""
|
|
30
31
|
|
|
32
|
+
import logging
|
|
33
|
+
from datetime import datetime, timezone
|
|
31
34
|
from typing import Literal
|
|
32
35
|
|
|
33
36
|
from fin_infra.credit.experian.auth import ExperianAuthManager
|
|
@@ -37,6 +40,10 @@ from fin_infra.models.credit import CreditReport, CreditScore
|
|
|
37
40
|
from fin_infra.providers.base import CreditProvider
|
|
38
41
|
from fin_infra.settings import Settings
|
|
39
42
|
|
|
43
|
+
# FCRA audit logger - use dedicated logger for compliance auditing
|
|
44
|
+
# This should be configured to write to a tamper-evident, append-only log
|
|
45
|
+
fcra_audit_logger = logging.getLogger("fin_infra.fcra_audit")
|
|
46
|
+
|
|
40
47
|
|
|
41
48
|
class ExperianProvider(CreditProvider):
|
|
42
49
|
"""Experian credit bureau provider with real API integration.
|
|
@@ -143,11 +150,14 @@ class ExperianProvider(CreditProvider):
|
|
|
143
150
|
"""Retrieve current credit score for a user from Experian API.
|
|
144
151
|
|
|
145
152
|
Makes real API call to Experian. Uses FCRA-compliant permissible purpose.
|
|
153
|
+
All credit pulls are logged for FCRA compliance (15 USC § 1681b).
|
|
146
154
|
|
|
147
155
|
Args:
|
|
148
156
|
user_id: User identifier (SSN hash or internal ID)
|
|
149
157
|
**kwargs: Additional parameters
|
|
150
158
|
- permissible_purpose: FCRA purpose (default: "account_review")
|
|
159
|
+
- requester_ip: IP address of requester (for audit log)
|
|
160
|
+
- requester_user_id: ID of user/service making the request
|
|
151
161
|
|
|
152
162
|
Returns:
|
|
153
163
|
CreditScore with real FICO score from Experian
|
|
@@ -162,25 +172,79 @@ class ExperianProvider(CreditProvider):
|
|
|
162
172
|
>>> print(score.score) # Real FICO score (300-850)
|
|
163
173
|
"""
|
|
164
174
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
176
|
+
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
177
|
+
|
|
178
|
+
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
179
|
+
# This log must be retained for at least 2 years per FCRA requirements
|
|
180
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
181
|
+
fcra_audit_logger.info(
|
|
182
|
+
"FCRA_CREDIT_PULL",
|
|
183
|
+
extra={
|
|
184
|
+
"action": "credit_score_pull",
|
|
185
|
+
"subject_user_id": user_id,
|
|
186
|
+
"requester_user_id": requester_user_id,
|
|
187
|
+
"requester_ip": requester_ip,
|
|
188
|
+
"permissible_purpose": permissible_purpose,
|
|
189
|
+
"provider": "experian",
|
|
190
|
+
"environment": self.environment,
|
|
191
|
+
"timestamp": timestamp,
|
|
192
|
+
"result": "pending",
|
|
193
|
+
}
|
|
170
194
|
)
|
|
171
195
|
|
|
172
|
-
|
|
173
|
-
|
|
196
|
+
try:
|
|
197
|
+
# Fetch from Experian API
|
|
198
|
+
data = await self._client.get_credit_score(
|
|
199
|
+
user_id,
|
|
200
|
+
permissible_purpose=permissible_purpose,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Parse response to CreditScore model
|
|
204
|
+
result = parse_credit_score(data, user_id=user_id)
|
|
205
|
+
|
|
206
|
+
# Log successful pull
|
|
207
|
+
fcra_audit_logger.info(
|
|
208
|
+
"FCRA_CREDIT_PULL_SUCCESS",
|
|
209
|
+
extra={
|
|
210
|
+
"action": "credit_score_pull",
|
|
211
|
+
"subject_user_id": user_id,
|
|
212
|
+
"requester_user_id": requester_user_id,
|
|
213
|
+
"timestamp": timestamp,
|
|
214
|
+
"result": "success",
|
|
215
|
+
"score_returned": result.score is not None,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# Log failed pull - still required for FCRA audit trail
|
|
223
|
+
fcra_audit_logger.warning(
|
|
224
|
+
"FCRA_CREDIT_PULL_FAILED",
|
|
225
|
+
extra={
|
|
226
|
+
"action": "credit_score_pull",
|
|
227
|
+
"subject_user_id": user_id,
|
|
228
|
+
"requester_user_id": requester_user_id,
|
|
229
|
+
"timestamp": timestamp,
|
|
230
|
+
"result": "error",
|
|
231
|
+
"error_type": type(e).__name__,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
raise
|
|
174
235
|
|
|
175
236
|
async def get_credit_report(self, user_id: str, **kwargs) -> CreditReport:
|
|
176
237
|
"""Retrieve full credit report for a user from Experian API.
|
|
177
238
|
|
|
178
239
|
Makes real API call to Experian. Includes FCRA-required permissible purpose header.
|
|
240
|
+
All credit pulls are logged for FCRA compliance (15 USC § 1681b).
|
|
179
241
|
|
|
180
242
|
Args:
|
|
181
243
|
user_id: User identifier (SSN hash or internal ID)
|
|
182
244
|
**kwargs: Additional parameters
|
|
183
245
|
- permissible_purpose: FCRA purpose (default: "account_review")
|
|
246
|
+
- requester_ip: IP address of requester (for audit log)
|
|
247
|
+
- requester_user_id: ID of user/service making the request
|
|
184
248
|
|
|
185
249
|
Returns:
|
|
186
250
|
CreditReport with real credit data from Experian
|
|
@@ -196,15 +260,69 @@ class ExperianProvider(CreditProvider):
|
|
|
196
260
|
>>> print(report.score.score) # Real FICO score
|
|
197
261
|
"""
|
|
198
262
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
263
|
+
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
264
|
+
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
265
|
+
|
|
266
|
+
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
267
|
+
# Full credit report pulls have stricter requirements than score-only pulls
|
|
268
|
+
# This log must be retained for at least 2 years per FCRA requirements
|
|
269
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
270
|
+
fcra_audit_logger.info(
|
|
271
|
+
"FCRA_CREDIT_PULL",
|
|
272
|
+
extra={
|
|
273
|
+
"action": "credit_report_pull",
|
|
274
|
+
"subject_user_id": user_id,
|
|
275
|
+
"requester_user_id": requester_user_id,
|
|
276
|
+
"requester_ip": requester_ip,
|
|
277
|
+
"permissible_purpose": permissible_purpose,
|
|
278
|
+
"provider": "experian",
|
|
279
|
+
"environment": self.environment,
|
|
280
|
+
"timestamp": timestamp,
|
|
281
|
+
"result": "pending",
|
|
282
|
+
"report_type": "full",
|
|
283
|
+
}
|
|
204
284
|
)
|
|
205
285
|
|
|
206
|
-
|
|
207
|
-
|
|
286
|
+
try:
|
|
287
|
+
# Fetch from Experian API
|
|
288
|
+
data = await self._client.get_credit_report(
|
|
289
|
+
user_id,
|
|
290
|
+
permissible_purpose=permissible_purpose,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Parse response to CreditReport model
|
|
294
|
+
result = parse_credit_report(data, user_id=user_id)
|
|
295
|
+
|
|
296
|
+
# Log successful pull
|
|
297
|
+
fcra_audit_logger.info(
|
|
298
|
+
"FCRA_CREDIT_PULL_SUCCESS",
|
|
299
|
+
extra={
|
|
300
|
+
"action": "credit_report_pull",
|
|
301
|
+
"subject_user_id": user_id,
|
|
302
|
+
"requester_user_id": requester_user_id,
|
|
303
|
+
"timestamp": timestamp,
|
|
304
|
+
"result": "success",
|
|
305
|
+
"accounts_returned": len(result.accounts) if result.accounts else 0,
|
|
306
|
+
"inquiries_returned": len(result.inquiries) if result.inquiries else 0,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
# Log failed pull - still required for FCRA audit trail
|
|
314
|
+
fcra_audit_logger.warning(
|
|
315
|
+
"FCRA_CREDIT_PULL_FAILED",
|
|
316
|
+
extra={
|
|
317
|
+
"action": "credit_report_pull",
|
|
318
|
+
"subject_user_id": user_id,
|
|
319
|
+
"requester_user_id": requester_user_id,
|
|
320
|
+
"timestamp": timestamp,
|
|
321
|
+
"result": "error",
|
|
322
|
+
"error_type": type(e).__name__,
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
raise
|
|
208
326
|
|
|
209
327
|
async def subscribe_to_changes(self, user_id: str, webhook_url: str, **kwargs) -> str:
|
|
210
328
|
"""Subscribe to credit score change notifications from Experian.
|