fin-infra 0.1.62__py3-none-any.whl → 0.1.69__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fin_infra/analytics/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
fin_infra/net_worth/models.py
CHANGED
|
@@ -19,6 +19,7 @@ Pydantic V2 models for net worth tracking.
|
|
|
19
19
|
|
|
20
20
|
from datetime import datetime
|
|
21
21
|
from enum import Enum
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
22
23
|
|
|
23
24
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
24
25
|
|
|
@@ -207,54 +208,100 @@ class AssetAllocation(BaseModel):
|
|
|
207
208
|
vehicles: float = Field(0.0, ge=0, description="Vehicle value")
|
|
208
209
|
other_assets: float = Field(0.0, ge=0, description="Other asset value")
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
211
|
+
if TYPE_CHECKING:
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def total_assets(self) -> float:
|
|
215
|
+
"""Sum of all asset categories."""
|
|
216
|
+
return (
|
|
217
|
+
self.cash
|
|
218
|
+
+ self.investments
|
|
219
|
+
+ self.crypto
|
|
220
|
+
+ self.real_estate
|
|
221
|
+
+ self.vehicles
|
|
222
|
+
+ self.other_assets
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def cash_percentage(self) -> float:
|
|
227
|
+
"""Cash as percentage of total assets."""
|
|
228
|
+
return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def investments_percentage(self) -> float:
|
|
232
|
+
"""Investments as percentage of total assets."""
|
|
233
|
+
return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def crypto_percentage(self) -> float:
|
|
237
|
+
"""Crypto as percentage of total assets."""
|
|
238
|
+
return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def real_estate_percentage(self) -> float:
|
|
242
|
+
"""Real estate as percentage of total assets."""
|
|
243
|
+
return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def vehicles_percentage(self) -> float:
|
|
247
|
+
"""Vehicles as percentage of total assets."""
|
|
248
|
+
return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def other_percentage(self) -> float:
|
|
252
|
+
"""Other assets as percentage of total assets."""
|
|
253
|
+
return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
254
|
+
|
|
255
|
+
else:
|
|
256
|
+
|
|
257
|
+
@computed_field
|
|
258
|
+
@property
|
|
259
|
+
def total_assets(self) -> float:
|
|
260
|
+
"""Sum of all asset categories."""
|
|
261
|
+
return (
|
|
262
|
+
self.cash
|
|
263
|
+
+ self.investments
|
|
264
|
+
+ self.crypto
|
|
265
|
+
+ self.real_estate
|
|
266
|
+
+ self.vehicles
|
|
267
|
+
+ self.other_assets
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@computed_field
|
|
271
|
+
@property
|
|
272
|
+
def cash_percentage(self) -> float:
|
|
273
|
+
"""Cash as percentage of total assets."""
|
|
274
|
+
return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
275
|
+
|
|
276
|
+
@computed_field
|
|
277
|
+
@property
|
|
278
|
+
def investments_percentage(self) -> float:
|
|
279
|
+
"""Investments as percentage of total assets."""
|
|
280
|
+
return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
281
|
+
|
|
282
|
+
@computed_field
|
|
283
|
+
@property
|
|
284
|
+
def crypto_percentage(self) -> float:
|
|
285
|
+
"""Crypto as percentage of total assets."""
|
|
286
|
+
return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
287
|
+
|
|
288
|
+
@computed_field
|
|
289
|
+
@property
|
|
290
|
+
def real_estate_percentage(self) -> float:
|
|
291
|
+
"""Real estate as percentage of total assets."""
|
|
292
|
+
return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
293
|
+
|
|
294
|
+
@computed_field
|
|
295
|
+
@property
|
|
296
|
+
def vehicles_percentage(self) -> float:
|
|
297
|
+
"""Vehicles as percentage of total assets."""
|
|
298
|
+
return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
299
|
+
|
|
300
|
+
@computed_field
|
|
301
|
+
@property
|
|
302
|
+
def other_percentage(self) -> float:
|
|
303
|
+
"""Other assets as percentage of total assets."""
|
|
304
|
+
return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
258
305
|
|
|
259
306
|
|
|
260
307
|
class LiabilityBreakdown(BaseModel):
|
|
@@ -288,74 +335,148 @@ class LiabilityBreakdown(BaseModel):
|
|
|
288
335
|
personal_loans: float = Field(0.0, ge=0, description="Personal loan balance")
|
|
289
336
|
lines_of_credit: float = Field(0.0, ge=0, description="Line of credit balance")
|
|
290
337
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
338
|
+
if TYPE_CHECKING:
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def total_liabilities(self) -> float:
|
|
342
|
+
"""Sum of all liability categories."""
|
|
343
|
+
return (
|
|
344
|
+
self.credit_cards
|
|
345
|
+
+ self.mortgages
|
|
346
|
+
+ self.auto_loans
|
|
347
|
+
+ self.student_loans
|
|
348
|
+
+ self.personal_loans
|
|
349
|
+
+ self.lines_of_credit
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def credit_cards_percentage(self) -> float:
|
|
354
|
+
"""Credit cards as percentage of total liabilities."""
|
|
355
|
+
return (
|
|
356
|
+
(self.credit_cards / self.total_liabilities * 100)
|
|
357
|
+
if self.total_liabilities > 0
|
|
358
|
+
else 0.0
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def mortgages_percentage(self) -> float:
|
|
363
|
+
"""Mortgages as percentage of total liabilities."""
|
|
364
|
+
return (
|
|
365
|
+
(self.mortgages / self.total_liabilities * 100)
|
|
366
|
+
if self.total_liabilities > 0
|
|
367
|
+
else 0.0
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def auto_loans_percentage(self) -> float:
|
|
372
|
+
"""Auto loans as percentage of total liabilities."""
|
|
373
|
+
return (
|
|
374
|
+
(self.auto_loans / self.total_liabilities * 100)
|
|
375
|
+
if self.total_liabilities > 0
|
|
376
|
+
else 0.0
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def student_loans_percentage(self) -> float:
|
|
381
|
+
"""Student loans as percentage of total liabilities."""
|
|
382
|
+
return (
|
|
383
|
+
(self.student_loans / self.total_liabilities * 100)
|
|
384
|
+
if self.total_liabilities > 0
|
|
385
|
+
else 0.0
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def personal_loans_percentage(self) -> float:
|
|
390
|
+
"""Personal loans as percentage of total liabilities."""
|
|
391
|
+
return (
|
|
392
|
+
(self.personal_loans / self.total_liabilities * 100)
|
|
393
|
+
if self.total_liabilities > 0
|
|
394
|
+
else 0.0
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def lines_of_credit_percentage(self) -> float:
|
|
399
|
+
"""Lines of credit as percentage of total liabilities."""
|
|
400
|
+
return (
|
|
401
|
+
(self.lines_of_credit / self.total_liabilities * 100)
|
|
402
|
+
if self.total_liabilities > 0
|
|
403
|
+
else 0.0
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
else:
|
|
407
|
+
|
|
408
|
+
@computed_field
|
|
409
|
+
@property
|
|
410
|
+
def total_liabilities(self) -> float:
|
|
411
|
+
"""Sum of all liability categories."""
|
|
412
|
+
return (
|
|
413
|
+
self.credit_cards
|
|
414
|
+
+ self.mortgages
|
|
415
|
+
+ self.auto_loans
|
|
416
|
+
+ self.student_loans
|
|
417
|
+
+ self.personal_loans
|
|
418
|
+
+ self.lines_of_credit
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
@computed_field
|
|
422
|
+
@property
|
|
423
|
+
def credit_cards_percentage(self) -> float:
|
|
424
|
+
"""Credit cards as percentage of total liabilities."""
|
|
425
|
+
return (
|
|
426
|
+
(self.credit_cards / self.total_liabilities * 100)
|
|
427
|
+
if self.total_liabilities > 0
|
|
428
|
+
else 0.0
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
@computed_field
|
|
432
|
+
@property
|
|
433
|
+
def mortgages_percentage(self) -> float:
|
|
434
|
+
"""Mortgages as percentage of total liabilities."""
|
|
435
|
+
return (
|
|
436
|
+
(self.mortgages / self.total_liabilities * 100)
|
|
437
|
+
if self.total_liabilities > 0
|
|
438
|
+
else 0.0
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
@computed_field
|
|
442
|
+
@property
|
|
443
|
+
def auto_loans_percentage(self) -> float:
|
|
444
|
+
"""Auto loans as percentage of total liabilities."""
|
|
445
|
+
return (
|
|
446
|
+
(self.auto_loans / self.total_liabilities * 100)
|
|
447
|
+
if self.total_liabilities > 0
|
|
448
|
+
else 0.0
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@computed_field
|
|
452
|
+
@property
|
|
453
|
+
def student_loans_percentage(self) -> float:
|
|
454
|
+
"""Student loans as percentage of total liabilities."""
|
|
455
|
+
return (
|
|
456
|
+
(self.student_loans / self.total_liabilities * 100)
|
|
457
|
+
if self.total_liabilities > 0
|
|
458
|
+
else 0.0
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@computed_field
|
|
462
|
+
@property
|
|
463
|
+
def personal_loans_percentage(self) -> float:
|
|
464
|
+
"""Personal loans as percentage of total liabilities."""
|
|
465
|
+
return (
|
|
466
|
+
(self.personal_loans / self.total_liabilities * 100)
|
|
467
|
+
if self.total_liabilities > 0
|
|
468
|
+
else 0.0
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@computed_field
|
|
472
|
+
@property
|
|
473
|
+
def lines_of_credit_percentage(self) -> float:
|
|
474
|
+
"""Lines of credit as percentage of total liabilities."""
|
|
475
|
+
return (
|
|
476
|
+
(self.lines_of_credit / self.total_liabilities * 100)
|
|
477
|
+
if self.total_liabilities > 0
|
|
478
|
+
else 0.0
|
|
479
|
+
)
|
|
359
480
|
|
|
360
481
|
|
|
361
482
|
class AssetDetail(BaseModel):
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"""Data normalization module for financial symbols and currencies."""
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
3
8
|
from fin_infra.normalization.currency_converter import (
|
|
4
9
|
CurrencyConverter,
|
|
5
10
|
CurrencyNotSupportedError,
|
|
@@ -64,7 +69,7 @@ def easy_normalization(
|
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
def add_normalization(
|
|
67
|
-
app: "FastAPI",
|
|
72
|
+
app: "FastAPI",
|
|
68
73
|
*,
|
|
69
74
|
prefix: str = "/normalize",
|
|
70
75
|
api_key: str | None = None,
|
|
@@ -110,11 +115,6 @@ def add_normalization(
|
|
|
110
115
|
- Integrated with svc-infra observability (request metrics)
|
|
111
116
|
- Scoped docs at {prefix}/docs for standalone documentation
|
|
112
117
|
"""
|
|
113
|
-
from typing import TYPE_CHECKING
|
|
114
|
-
|
|
115
|
-
if TYPE_CHECKING:
|
|
116
|
-
from fastapi import FastAPI
|
|
117
|
-
|
|
118
118
|
# Import FastAPI dependencies
|
|
119
119
|
from fastapi import Query, HTTPException
|
|
120
120
|
|
|
@@ -145,19 +145,21 @@ def add_normalization(
|
|
|
145
145
|
@router.get("/convert")
|
|
146
146
|
async def convert_currency(
|
|
147
147
|
amount: float = Query(..., description="Amount to convert"),
|
|
148
|
-
from_currency: str = Query(
|
|
148
|
+
from_currency: str = Query(
|
|
149
|
+
..., alias="from", description="Source currency code (e.g., USD)"
|
|
150
|
+
),
|
|
149
151
|
to_currency: str = Query(..., alias="to", description="Target currency code (e.g., EUR)"),
|
|
150
152
|
):
|
|
151
153
|
"""Convert amount between currencies."""
|
|
152
154
|
try:
|
|
153
|
-
result = await converter.
|
|
155
|
+
result = await converter.convert_with_details(amount, from_currency, to_currency)
|
|
154
156
|
return {
|
|
155
|
-
"amount": amount,
|
|
156
|
-
"from_currency": from_currency,
|
|
157
|
-
"to_currency": to_currency,
|
|
158
|
-
"result": result.
|
|
157
|
+
"amount": result.amount,
|
|
158
|
+
"from_currency": result.from_currency,
|
|
159
|
+
"to_currency": result.to_currency,
|
|
160
|
+
"result": result.converted,
|
|
159
161
|
"rate": result.rate,
|
|
160
|
-
"timestamp": result.
|
|
162
|
+
"timestamp": result.date.isoformat() if result.date else None,
|
|
161
163
|
}
|
|
162
164
|
except CurrencyNotSupportedError as e:
|
|
163
165
|
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from datetime import date as DateType
|
|
5
|
-
from typing import Optional
|
|
5
|
+
from typing import Optional, cast
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
@@ -66,10 +66,10 @@ class ExchangeRateClient:
|
|
|
66
66
|
raise ExchangeRateAPIError(
|
|
67
67
|
f"API returned error: {data.get('error-type', 'unknown')}"
|
|
68
68
|
)
|
|
69
|
-
return data["conversion_rates"]
|
|
69
|
+
return cast(dict[str, float], data["conversion_rates"])
|
|
70
70
|
else:
|
|
71
71
|
# Free tier response format
|
|
72
|
-
return data["rates"]
|
|
72
|
+
return cast(dict[str, float], data["rates"])
|
|
73
73
|
|
|
74
74
|
except httpx.HTTPError as e:
|
|
75
75
|
raise ExchangeRateAPIError(f"HTTP error fetching rates: {e}")
|
fin_infra/obs/classifier.py
CHANGED
|
@@ -112,9 +112,9 @@ def financial_route_classifier(route_path: str, method: str) -> str:
|
|
|
112
112
|
|
|
113
113
|
|
|
114
114
|
def compose_classifiers(
|
|
115
|
-
*classifiers: Callable[[str], str],
|
|
115
|
+
*classifiers: Callable[[str, str], str],
|
|
116
116
|
default: str = "public",
|
|
117
|
-
) -> Callable[[str], str]:
|
|
117
|
+
) -> Callable[[str, str], str]:
|
|
118
118
|
"""
|
|
119
119
|
Compose multiple route classifiers with fallback logic.
|
|
120
120
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import date, datetime, timedelta
|
|
4
|
+
from typing import Any, cast
|
|
4
5
|
|
|
5
6
|
# Plaid SDK v25+ uses new API structure
|
|
6
7
|
try:
|
|
@@ -33,7 +34,7 @@ class PlaidClient(BankingProvider):
|
|
|
33
34
|
environment: str | None = None,
|
|
34
35
|
) -> None:
|
|
35
36
|
"""Initialize Plaid client with either Settings object or individual parameters.
|
|
36
|
-
|
|
37
|
+
|
|
37
38
|
Args:
|
|
38
39
|
settings: Settings object (legacy pattern)
|
|
39
40
|
client_id: Plaid client ID (preferred - from env or passed directly)
|
|
@@ -44,14 +45,14 @@ class PlaidClient(BankingProvider):
|
|
|
44
45
|
raise RuntimeError(
|
|
45
46
|
"plaid-python SDK not available or import failed; check installed version (requires v25+)"
|
|
46
47
|
)
|
|
47
|
-
|
|
48
|
+
|
|
48
49
|
# Support both patterns: Settings object or individual params
|
|
49
50
|
if settings is not None:
|
|
50
51
|
# Legacy pattern with Settings object
|
|
51
52
|
client_id = client_id or settings.plaid_client_id
|
|
52
53
|
secret = secret or settings.plaid_secret
|
|
53
54
|
environment = environment or settings.plaid_env
|
|
54
|
-
|
|
55
|
+
|
|
55
56
|
# Map environment string to Plaid Environment enum
|
|
56
57
|
# Note: Plaid only has Sandbox and Production (no Development in SDK)
|
|
57
58
|
env_str = environment or "sandbox"
|
|
@@ -60,22 +61,22 @@ class PlaidClient(BankingProvider):
|
|
|
60
61
|
"development": plaid.Environment.Sandbox, # Map development to sandbox (Plaid SDK limitation)
|
|
61
62
|
"production": plaid.Environment.Production,
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
|
|
64
65
|
if env_str not in env_map:
|
|
65
66
|
raise ValueError(
|
|
66
67
|
f"Invalid Plaid environment: '{env_str}'. "
|
|
67
68
|
f"Must be one of: sandbox, development, production"
|
|
68
69
|
)
|
|
69
|
-
|
|
70
|
+
|
|
70
71
|
host = env_map[env_str]
|
|
71
|
-
|
|
72
|
+
|
|
72
73
|
# Configure Plaid client (v8.0.0+ API)
|
|
73
74
|
configuration = plaid.Configuration(
|
|
74
75
|
host=host,
|
|
75
76
|
api_key={
|
|
76
77
|
"clientId": client_id,
|
|
77
78
|
"secret": secret,
|
|
78
|
-
}
|
|
79
|
+
},
|
|
79
80
|
)
|
|
80
81
|
api_client = plaid.ApiClient(configuration)
|
|
81
82
|
self.client = plaid_api.PlaidApi(api_client)
|
|
@@ -85,18 +86,18 @@ class PlaidClient(BankingProvider):
|
|
|
85
86
|
user=LinkTokenCreateRequestUser(client_user_id=user_id),
|
|
86
87
|
client_name="fin-infra",
|
|
87
88
|
products=[
|
|
88
|
-
Products("auth"),
|
|
89
|
-
Products("transactions"),
|
|
90
|
-
Products("liabilities"),
|
|
91
|
-
Products("investments"),
|
|
92
|
-
Products("assets"),
|
|
93
|
-
Products("identity"),
|
|
89
|
+
Products("auth"), # Account/routing numbers for ACH
|
|
90
|
+
Products("transactions"), # Transaction history
|
|
91
|
+
Products("liabilities"), # Credit cards, loans, student loans
|
|
92
|
+
Products("investments"), # Brokerage, retirement accounts
|
|
93
|
+
Products("assets"), # Asset reports for lending/verification
|
|
94
|
+
Products("identity"), # Account holder info (name, email, phone)
|
|
94
95
|
],
|
|
95
96
|
country_codes=[CountryCode("US")],
|
|
96
97
|
language="en",
|
|
97
98
|
)
|
|
98
99
|
response = self.client.link_token_create(request)
|
|
99
|
-
return response["link_token"]
|
|
100
|
+
return cast(str, response["link_token"])
|
|
100
101
|
|
|
101
102
|
def exchange_public_token(self, public_token: str) -> dict:
|
|
102
103
|
request = ItemPublicTokenExchangeRequest(public_token=public_token)
|
|
@@ -121,7 +122,7 @@ class PlaidClient(BankingProvider):
|
|
|
121
122
|
start = end - timedelta(days=30)
|
|
122
123
|
start_date = start_date or start.isoformat()
|
|
123
124
|
end_date = end_date or end.isoformat()
|
|
124
|
-
|
|
125
|
+
|
|
125
126
|
request = TransactionsGetRequest(
|
|
126
127
|
access_token=access_token,
|
|
127
128
|
start_date=date.fromisoformat(start_date),
|
|
@@ -135,19 +136,19 @@ class PlaidClient(BankingProvider):
|
|
|
135
136
|
request = AccountsBalanceGetRequest(access_token=access_token)
|
|
136
137
|
response = self.client.accounts_balance_get(request)
|
|
137
138
|
accounts = [acc.to_dict() for acc in response["accounts"]]
|
|
138
|
-
|
|
139
|
+
|
|
139
140
|
if account_id:
|
|
140
141
|
# Filter to specific account
|
|
141
142
|
for account in accounts:
|
|
142
143
|
if account.get("account_id") == account_id:
|
|
143
144
|
return {"balances": [account.get("balances", {})]}
|
|
144
145
|
return {"balances": []}
|
|
145
|
-
|
|
146
|
+
|
|
146
147
|
# Return all balances
|
|
147
148
|
return {"balances": [acc.get("balances", {}) for acc in accounts]}
|
|
148
149
|
|
|
149
|
-
def identity(self, access_token: str) -> dict:
|
|
150
|
+
def identity(self, access_token: str) -> dict[Any, Any]:
|
|
150
151
|
"""Fetch identity/account holder information."""
|
|
151
152
|
request = IdentityGetRequest(access_token=access_token)
|
|
152
153
|
response = self.client.identity_get(request)
|
|
153
|
-
return response.to_dict()
|
|
154
|
+
return cast(dict[Any, Any], response.to_dict())
|
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import ssl
|
|
26
26
|
import httpx
|
|
27
|
-
from typing import Any
|
|
27
|
+
from typing import Any, cast
|
|
28
28
|
|
|
29
29
|
from ..base import BankingProvider
|
|
30
30
|
|
|
@@ -93,7 +93,13 @@ class TellerClient(BankingProvider):
|
|
|
93
93
|
ssl_context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
94
94
|
client_kwargs["verify"] = ssl_context
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# Create client with explicit parameters to satisfy type checker
|
|
97
|
+
self.client = httpx.Client(
|
|
98
|
+
base_url=str(client_kwargs["base_url"]),
|
|
99
|
+
timeout=float(client_kwargs["timeout"]), # type: ignore[arg-type]
|
|
100
|
+
headers=client_kwargs["headers"], # type: ignore[arg-type]
|
|
101
|
+
verify=client_kwargs.get("verify", True), # type: ignore[arg-type]
|
|
102
|
+
)
|
|
97
103
|
|
|
98
104
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
99
105
|
"""Make HTTP request to Teller API with error handling.
|
|
@@ -139,7 +145,7 @@ class TellerClient(BankingProvider):
|
|
|
139
145
|
"products": ["accounts", "transactions", "balances", "identity"],
|
|
140
146
|
},
|
|
141
147
|
)
|
|
142
|
-
return response.get("enrollment_id", "")
|
|
148
|
+
return cast(str, response.get("enrollment_id", ""))
|
|
143
149
|
|
|
144
150
|
def exchange_public_token(self, public_token: str) -> dict:
|
|
145
151
|
"""Exchange public token for access token.
|
|
@@ -186,7 +192,7 @@ class TellerClient(BankingProvider):
|
|
|
186
192
|
auth=(access_token, ""),
|
|
187
193
|
)
|
|
188
194
|
response.raise_for_status()
|
|
189
|
-
return response.json()
|
|
195
|
+
return cast(list[dict[Any, Any]], response.json())
|
|
190
196
|
|
|
191
197
|
def transactions(
|
|
192
198
|
self,
|
|
@@ -229,7 +235,7 @@ class TellerClient(BankingProvider):
|
|
|
229
235
|
params=params,
|
|
230
236
|
)
|
|
231
237
|
response.raise_for_status()
|
|
232
|
-
return response.json()
|
|
238
|
+
return cast(list[dict[Any, Any]], response.json())
|
|
233
239
|
|
|
234
240
|
def balances(self, access_token: str, account_id: str | None = None) -> dict:
|
|
235
241
|
"""Fetch current balances.
|
|
@@ -261,7 +267,7 @@ class TellerClient(BankingProvider):
|
|
|
261
267
|
)
|
|
262
268
|
|
|
263
269
|
response.raise_for_status()
|
|
264
|
-
return response.json()
|
|
270
|
+
return cast(dict[Any, Any], response.json())
|
|
265
271
|
|
|
266
272
|
def identity(self, access_token: str) -> dict:
|
|
267
273
|
"""Fetch identity/account holder information.
|
|
@@ -285,7 +291,7 @@ class TellerClient(BankingProvider):
|
|
|
285
291
|
auth=(access_token, ""),
|
|
286
292
|
)
|
|
287
293
|
response.raise_for_status()
|
|
288
|
-
return response.json()
|
|
294
|
+
return cast(dict[Any, Any], response.json())
|
|
289
295
|
|
|
290
296
|
def __del__(self) -> None:
|
|
291
297
|
"""Close HTTP client on cleanup."""
|