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 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
  ]
@@ -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, float] = defaultdict(float)
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, float] = defaultdict(float)
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, float],
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, float],
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=float(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
- Uses svc-infra cache tag invalidation to clear all tokens with tag
150
- "oauth:experian". Useful when token is rejected by API.
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
- await invalidate_tags("oauth:experian")
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
- class ExperianAPIError(Exception):
31
- """Base exception for Experian API errors."""
32
-
33
- def __init__(self, message: str, status_code: int | None = None, response: dict | None = None):
34
- super().__init__(message)
35
- self.status_code = status_code
36
- self.response = response
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
- # Fetch from Experian API
167
- data = await self._client.get_credit_score(
168
- user_id,
169
- permissible_purpose=permissible_purpose,
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
- # Parse response to CreditScore model
173
- return parse_credit_score(data, user_id=user_id)
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
- # Fetch from Experian API
201
- data = await self._client.get_credit_report(
202
- user_id,
203
- permissible_purpose=permissible_purpose,
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
- # Parse response to CreditReport model
207
- return parse_credit_report(data, user_id=user_id)
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.