fin-infra 0.1.56__tar.gz → 0.1.58__tar.gz

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 (181) hide show
  1. {fin_infra-0.1.56 → fin_infra-0.1.58}/PKG-INFO +1 -1
  2. {fin_infra-0.1.56 → fin_infra-0.1.58}/pyproject.toml +1 -1
  3. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/banking/history.py +33 -0
  4. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/experian/auth.py +9 -6
  5. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/experian/provider.py +132 -14
  6. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/ease.py +2 -2
  7. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/providers/base.py +2 -1
  8. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/providers/snaptrade.py +32 -18
  9. fin_infra-0.1.58/src/fin_infra/models/accounts.py +42 -0
  10. fin_infra-0.1.58/src/fin_infra/models/transactions.py +30 -0
  11. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/calculator.py +32 -6
  12. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/banking/plaid_client.py +10 -2
  13. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/base.py +23 -3
  14. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/brokerage/alpaca.py +11 -2
  15. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/settings.py +1 -1
  16. fin_infra-0.1.56/src/fin_infra/models/accounts.py +0 -26
  17. fin_infra-0.1.56/src/fin_infra/models/transactions.py +0 -16
  18. {fin_infra-0.1.56 → fin_infra-0.1.58}/LICENSE +0 -0
  19. {fin_infra-0.1.56 → fin_infra-0.1.58}/README.md +0 -0
  20. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/__init__.py +0 -0
  21. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/__main__.py +0 -0
  22. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/__init__.py +0 -0
  23. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/add.py +0 -0
  24. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/cash_flow.py +0 -0
  25. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/ease.py +0 -0
  26. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/models.py +0 -0
  27. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/portfolio.py +0 -0
  28. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/projections.py +0 -0
  29. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/rebalancing.py +0 -0
  30. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/savings.py +0 -0
  31. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/scenarios.py +0 -0
  32. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/analytics/spending.py +0 -0
  33. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/banking/__init__.py +0 -0
  34. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/banking/utils.py +0 -0
  35. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/brokerage/__init__.py +0 -0
  36. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/__init__.py +0 -0
  37. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/add.py +0 -0
  38. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/alerts.py +0 -0
  39. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/ease.py +0 -0
  40. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/models.py +0 -0
  41. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/scaffold_templates/README.md +0 -0
  42. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/scaffold_templates/__init__.py +0 -0
  43. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/scaffold_templates/models.py.tmpl +0 -0
  44. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/scaffold_templates/repository.py.tmpl +0 -0
  45. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/scaffold_templates/schemas.py.tmpl +0 -0
  46. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/templates.py +0 -0
  47. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/budgets/tracker.py +0 -0
  48. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/cashflows/__init__.py +0 -0
  49. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/cashflows/core.py +0 -0
  50. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/__init__.py +0 -0
  51. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/add.py +0 -0
  52. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/ease.py +0 -0
  53. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/engine.py +0 -0
  54. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/llm_layer.py +0 -0
  55. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/models.py +0 -0
  56. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/rules.py +0 -0
  57. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/categorization/taxonomy.py +0 -0
  58. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/chat/__init__.py +0 -0
  59. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/chat/ease.py +0 -0
  60. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/chat/planning.py +0 -0
  61. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/cli/__init__.py +0 -0
  62. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/cli/cmds/__init__.py +0 -0
  63. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/cli/cmds/scaffold_cmds.py +0 -0
  64. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/clients/__init__.py +0 -0
  65. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/clients/base.py +0 -0
  66. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/clients/plaid.py +0 -0
  67. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/compliance/__init__.py +0 -0
  68. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/__init__.py +0 -0
  69. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/add.py +0 -0
  70. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/experian/__init__.py +0 -0
  71. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/experian/client.py +0 -0
  72. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/experian/parser.py +0 -0
  73. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/credit/mock.py +0 -0
  74. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/crypto/__init__.py +0 -0
  75. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/crypto/insights.py +0 -0
  76. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/__init__.py +0 -0
  77. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/add.py +0 -0
  78. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/analysis.py +0 -0
  79. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/ease.py +0 -0
  80. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/models.py +0 -0
  81. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/ocr.py +0 -0
  82. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/documents/storage.py +0 -0
  83. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/__init__.py +0 -0
  84. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/add.py +0 -0
  85. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/funding.py +0 -0
  86. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/management.py +0 -0
  87. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/milestones.py +0 -0
  88. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/models.py +0 -0
  89. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/scaffold_templates/README.md +0 -0
  90. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/scaffold_templates/__init__.py +0 -0
  91. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/scaffold_templates/models.py.tmpl +0 -0
  92. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/scaffold_templates/repository.py.tmpl +0 -0
  93. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/goals/scaffold_templates/schemas.py.tmpl +0 -0
  94. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/insights/__init__.py +0 -0
  95. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/insights/aggregator.py +0 -0
  96. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/insights/models.py +0 -0
  97. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/__init__.py +0 -0
  98. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/add.py +0 -0
  99. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/models.py +0 -0
  100. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/providers/__init__.py +0 -0
  101. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/providers/plaid.py +0 -0
  102. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/scaffold_templates/README.md +0 -0
  103. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/scaffold_templates/__init__.py +0 -0
  104. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/scaffold_templates/models.py.tmpl +0 -0
  105. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/scaffold_templates/repository.py.tmpl +0 -0
  106. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/investments/scaffold_templates/schemas.py.tmpl +0 -0
  107. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/markets/__init__.py +0 -0
  108. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/__init__.py +0 -0
  109. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/brokerage.py +0 -0
  110. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/candle.py +0 -0
  111. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/credit.py +0 -0
  112. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/money.py +0 -0
  113. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/quotes.py +0 -0
  114. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/models/tax.py +0 -0
  115. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/__init__.py +0 -0
  116. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/add.py +0 -0
  117. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/aggregator.py +0 -0
  118. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/ease.py +0 -0
  119. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/goals.py +0 -0
  120. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/insights.py +0 -0
  121. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/models.py +0 -0
  122. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/scaffold_templates/README.md +0 -0
  123. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/scaffold_templates/__init__.py +0 -0
  124. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/scaffold_templates/models.py.tmpl +0 -0
  125. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/scaffold_templates/repository.py.tmpl +0 -0
  126. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/net_worth/scaffold_templates/schemas.py.tmpl +0 -0
  127. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/__init__.py +0 -0
  128. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/currency_converter.py +0 -0
  129. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/models.py +0 -0
  130. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/providers/__init__.py +0 -0
  131. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/providers/exchangerate.py +0 -0
  132. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/providers/static_mappings.py +0 -0
  133. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/normalization/symbol_resolver.py +0 -0
  134. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/obs/__init__.py +0 -0
  135. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/obs/classifier.py +0 -0
  136. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/__init__.py +0 -0
  137. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/banking/base.py +0 -0
  138. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/banking/teller_client.py +0 -0
  139. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/brokerage/base.py +0 -0
  140. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/credit/experian.py +0 -0
  141. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/identity/stripe_identity.py +0 -0
  142. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/market/alphavantage.py +0 -0
  143. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/market/base.py +0 -0
  144. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/market/ccxt_crypto.py +0 -0
  145. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/market/coingecko.py +0 -0
  146. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/market/yahoo.py +0 -0
  147. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/registry.py +0 -0
  148. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/tax/__init__.py +0 -0
  149. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/tax/irs.py +0 -0
  150. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/tax/mock.py +0 -0
  151. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/providers/tax/taxbit.py +0 -0
  152. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/py.typed +0 -0
  153. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/__init__.py +0 -0
  154. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/add.py +0 -0
  155. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/detector.py +0 -0
  156. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/detectors_llm.py +0 -0
  157. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/ease.py +0 -0
  158. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/insights.py +0 -0
  159. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/models.py +0 -0
  160. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/normalizer.py +0 -0
  161. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/normalizers.py +0 -0
  162. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/recurring/summary.py +0 -0
  163. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/scaffold/__init__.py +0 -0
  164. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/scaffold/budgets.py +0 -0
  165. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/scaffold/goals.py +0 -0
  166. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/__init__.py +0 -0
  167. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/add.py +0 -0
  168. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/audit.py +0 -0
  169. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/encryption.py +0 -0
  170. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/models.py +0 -0
  171. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/pii_filter.py +0 -0
  172. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/pii_patterns.py +0 -0
  173. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/security/token_store.py +0 -0
  174. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/tax/__init__.py +0 -0
  175. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/tax/add.py +0 -0
  176. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/tax/tlh.py +0 -0
  177. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/utils/__init__.py +0 -0
  178. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/utils/http.py +0 -0
  179. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/utils/retry.py +0 -0
  180. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/utils.py +0 -0
  181. {fin_infra-0.1.56 → fin_infra-0.1.58}/src/fin_infra/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fin-infra
3
- Version: 0.1.56
3
+ Version: 0.1.58
4
4
  Summary: Financial infrastructure toolkit: banking connections, market data, credit, cashflows, and brokerage integrations
5
5
  License: MIT
6
6
  Keywords: finance,banking,plaid,brokerage,markets,credit,tax,cashflow,fintech,infra
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "fin-infra"
3
- version = "0.1.56"
3
+ version = "0.1.58"
4
4
  description = "Financial infrastructure toolkit: banking connections, market data, credit, cashflows, and brokerage integrations"
5
5
  authors = ["Ali Khatami <aliikhatami94@gmail.com>"]
6
6
  license = "MIT"
@@ -4,6 +4,9 @@ This module provides functionality to record and retrieve historical account bal
4
4
  snapshots over time. This enables balance trend analysis, sparklines, and time-series
5
5
  visualizations in fintech dashboards.
6
6
 
7
+ ⚠️ WARNING: This module uses IN-MEMORY storage by default. All data is LOST on restart.
8
+ For production use, integrate with svc-infra SQL database or set FIN_INFRA_STORAGE_BACKEND.
9
+
7
10
  Features:
8
11
  - Record daily balance snapshots for accounts
9
12
  - Store snapshots in time-series optimized format
@@ -36,6 +39,8 @@ Integration with svc-infra:
36
39
 
37
40
  from __future__ import annotations
38
41
 
42
+ import logging
43
+ import os
39
44
  from datetime import date, datetime, timedelta
40
45
  from typing import List, Optional
41
46
  from pydantic import BaseModel, Field, ConfigDict
@@ -50,8 +55,31 @@ __all__ = [
50
55
  ]
51
56
 
52
57
 
58
+ _logger = logging.getLogger(__name__)
59
+
53
60
  # In-memory storage for testing (will be replaced with SQL database in production)
61
+ # ⚠️ WARNING: All data is LOST on restart when using in-memory storage!
54
62
  _balance_snapshots: List[BalanceSnapshot] = []
63
+ _production_warning_logged = False
64
+
65
+
66
+ def _check_in_memory_warning() -> None:
67
+ """Log a warning if using in-memory storage in production."""
68
+ global _production_warning_logged
69
+ if _production_warning_logged:
70
+ return
71
+
72
+ env = os.getenv("ENV", "development").lower()
73
+ storage_backend = os.getenv("FIN_INFRA_STORAGE_BACKEND", "memory").lower()
74
+
75
+ if env in ("production", "staging") and storage_backend == "memory":
76
+ _logger.warning(
77
+ "⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
78
+ "All balance snapshots will be LOST on restart. "
79
+ "Set FIN_INFRA_STORAGE_BACKEND=sql for production persistence.",
80
+ env,
81
+ )
82
+ _production_warning_logged = True
55
83
 
56
84
 
57
85
  class BalanceSnapshot(BaseModel):
@@ -87,6 +115,8 @@ def record_balance_snapshot(
87
115
  This function stores a point-in-time balance record for trend analysis.
88
116
  In production, this would write to a SQL database via svc-infra.
89
117
 
118
+ ⚠️ WARNING: Uses in-memory storage by default. Data is LOST on restart!
119
+
90
120
  Args:
91
121
  account_id: Account identifier
92
122
  balance: Account balance at the snapshot time
@@ -103,6 +133,9 @@ def record_balance_snapshot(
103
133
  - In production, use unique constraint on (account_id, date) in SQL
104
134
  - Consider using svc-infra jobs for automatic daily snapshots
105
135
  """
136
+ # Check if in-memory storage is being used in production
137
+ _check_in_memory_warning()
138
+
106
139
  snapshot = BalanceSnapshot(
107
140
  account_id=account_id,
108
141
  balance=balance,
@@ -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}")
@@ -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.
@@ -46,7 +46,7 @@ def easy_investments(
46
46
  Plaid:
47
47
  - PLAID_CLIENT_ID: Plaid client ID
48
48
  - PLAID_SECRET: Plaid secret key
49
- - PLAID_ENV: Environment (sandbox/development/production), default: sandbox
49
+ - PLAID_ENVIRONMENT: Environment (sandbox/development/production), default: sandbox
50
50
 
51
51
  SnapTrade:
52
52
  - SNAPTRADE_CLIENT_ID: SnapTrade client ID
@@ -177,7 +177,7 @@ def _create_plaid_provider(**config: Any) -> InvestmentProvider:
177
177
  # Get credentials from config or environment
178
178
  client_id = config.get("client_id") or os.getenv("PLAID_CLIENT_ID")
179
179
  secret = config.get("secret") or os.getenv("PLAID_SECRET")
180
- environment = config.get("environment") or os.getenv("PLAID_ENV", "sandbox")
180
+ environment = config.get("environment") or os.getenv("PLAID_ENVIRONMENT", "sandbox")
181
181
 
182
182
  # Validate required credentials
183
183
  if not client_id or not secret:
@@ -217,8 +217,9 @@ class InvestmentProvider(ABC):
217
217
  )
218
218
 
219
219
  total_gain_loss = total_value - total_cost_basis
220
+ # Use != 0 to handle short sales (negative cost basis)
220
221
  total_gain_loss_percent = (
221
- (total_gain_loss / total_cost_basis * 100) if total_cost_basis > 0 else 0.0
222
+ (total_gain_loss / total_cost_basis * 100) if total_cost_basis != 0 else 0.0
222
223
  )
223
224
 
224
225
  return {
@@ -20,7 +20,6 @@ from ..models import (
20
20
  InvestmentAccount,
21
21
  InvestmentTransaction,
22
22
  Security,
23
- SecurityType,
24
23
  TransactionType,
25
24
  )
26
25
  from .base import InvestmentProvider
@@ -96,6 +95,25 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
96
95
  timeout=30.0,
97
96
  )
98
97
 
98
+ def _auth_headers(self, user_id: str, user_secret: str) -> Dict[str, str]:
99
+ """Build authentication headers for SnapTrade API requests.
100
+
101
+ SECURITY: User secrets are passed in headers, NOT URL params.
102
+ URL params are logged in access logs, browser history, and proxy logs.
103
+ Headers are not logged by default in most web servers.
104
+
105
+ Args:
106
+ user_id: SnapTrade user ID
107
+ user_secret: SnapTrade user secret (sensitive!)
108
+
109
+ Returns:
110
+ Dict with authentication headers
111
+ """
112
+ return {
113
+ "userId": user_id,
114
+ "userSecret": user_secret,
115
+ }
116
+
99
117
  async def get_holdings(
100
118
  self,
101
119
  access_token: str,
@@ -123,12 +141,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
123
141
  ... print(f"{holding.security.ticker_symbol}: P&L ${pnl}")
124
142
  """
125
143
  user_id, user_secret = self._parse_access_token(access_token)
144
+ auth_headers = self._auth_headers(user_id, user_secret)
126
145
 
127
146
  try:
128
147
  # Get all accounts
129
148
  accounts_url = f"{self.base_url}/accounts"
130
- params = {"userId": user_id, "userSecret": user_secret}
131
- response = await self.client.get(accounts_url, params=params)
149
+ response = await self.client.get(accounts_url, headers=auth_headers)
132
150
  response.raise_for_status()
133
151
  accounts = await response.json()
134
152
 
@@ -143,8 +161,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
143
161
  positions_url = (
144
162
  f"{self.base_url}/accounts/{account_id}/positions"
145
163
  )
146
- pos_params = {"userId": user_id, "userSecret": user_secret}
147
- pos_response = await self.client.get(positions_url, params=pos_params)
164
+ pos_response = await self.client.get(positions_url, headers=auth_headers)
148
165
  pos_response.raise_for_status()
149
166
  positions = await pos_response.json()
150
167
 
@@ -192,12 +209,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
192
209
  raise ValueError("start_date must be before end_date")
193
210
 
194
211
  user_id, user_secret = self._parse_access_token(access_token)
212
+ auth_headers = self._auth_headers(user_id, user_secret)
195
213
 
196
214
  try:
197
215
  # Get all accounts
198
216
  accounts_url = f"{self.base_url}/accounts"
199
- params = {"userId": user_id, "userSecret": user_secret}
200
- response = await self.client.get(accounts_url, params=params)
217
+ response = await self.client.get(accounts_url, headers=auth_headers)
201
218
  response.raise_for_status()
202
219
  accounts = await response.json()
203
220
 
@@ -212,13 +229,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
212
229
  transactions_url = (
213
230
  f"{self.base_url}/accounts/{account_id}/transactions"
214
231
  )
232
+ # Date params are non-sensitive, only auth goes in headers
215
233
  tx_params = {
216
- "userId": user_id,
217
- "userSecret": user_secret,
218
234
  "startDate": start_date.isoformat(),
219
235
  "endDate": end_date.isoformat(),
220
236
  }
221
- tx_response = await self.client.get(transactions_url, params=tx_params)
237
+ tx_response = await self.client.get(transactions_url, params=tx_params, headers=auth_headers)
222
238
  tx_response.raise_for_status()
223
239
  transactions = await tx_response.json()
224
240
 
@@ -297,12 +313,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
297
313
  ... print(f" P&L: {account.total_unrealized_gain_loss_percent:.2f}%")
298
314
  """
299
315
  user_id, user_secret = self._parse_access_token(access_token)
316
+ auth_headers = self._auth_headers(user_id, user_secret)
300
317
 
301
318
  try:
302
319
  # Get all accounts
303
320
  accounts_url = f"{self.base_url}/accounts"
304
- params = {"userId": user_id, "userSecret": user_secret}
305
- response = await self.client.get(accounts_url, params=params)
321
+ response = await self.client.get(accounts_url, headers=auth_headers)
306
322
  response.raise_for_status()
307
323
  accounts = await response.json()
308
324
 
@@ -315,8 +331,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
315
331
  positions_url = (
316
332
  f"{self.base_url}/accounts/{account_id}/positions"
317
333
  )
318
- pos_params = {"userId": user_id, "userSecret": user_secret}
319
- pos_response = await self.client.get(positions_url, params=pos_params)
334
+ pos_response = await self.client.get(positions_url, headers=auth_headers)
320
335
  pos_response.raise_for_status()
321
336
  positions = await pos_response.json()
322
337
 
@@ -328,8 +343,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
328
343
 
329
344
  # Get account balances
330
345
  balances_url = f"{self.base_url}/accounts/{account_id}/balances"
331
- bal_params = {"userId": user_id, "userSecret": user_secret}
332
- bal_response = await self.client.get(balances_url, params=bal_params)
346
+ bal_response = await self.client.get(balances_url, headers=auth_headers)
333
347
  bal_response.raise_for_status()
334
348
  balances = await bal_response.json()
335
349
 
@@ -373,11 +387,11 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
373
387
  ... print(f"Connected: {conn['brokerage_name']}")
374
388
  """
375
389
  user_id, user_secret = self._parse_access_token(access_token)
390
+ auth_headers = self._auth_headers(user_id, user_secret)
376
391
 
377
392
  try:
378
393
  url = f"{self.base_url}/connections"
379
- params = {"userId": user_id, "userSecret": user_secret}
380
- response = await self.client.get(url, params=params)
394
+ response = await self.client.get(url, headers=auth_headers)
381
395
  response.raise_for_status()
382
396
  return await response.json()
383
397
 
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal
4
+ from enum import Enum
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, field_validator
8
+
9
+
10
+ class AccountType(str, Enum):
11
+ checking = "checking"
12
+ savings = "savings"
13
+ credit = "credit"
14
+ investment = "investment"
15
+ loan = "loan"
16
+ other = "other"
17
+
18
+
19
+ class Account(BaseModel):
20
+ """Financial account model.
21
+
22
+ Uses Decimal for balance fields to prevent floating-point precision errors
23
+ in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
24
+ """
25
+ id: str
26
+ name: str
27
+ type: AccountType
28
+ mask: Optional[str] = None
29
+ currency: str = "USD"
30
+ institution: Optional[str] = None
31
+ balance_available: Optional[Decimal] = None
32
+ balance_current: Optional[Decimal] = None
33
+
34
+ @field_validator("balance_available", "balance_current", mode="before")
35
+ @classmethod
36
+ def _coerce_balance_to_decimal(cls, v):
37
+ """Coerce float/int to Decimal for backwards compatibility."""
38
+ if v is None:
39
+ return v
40
+ if isinstance(v, (int, float)):
41
+ return Decimal(str(v))
42
+ return v
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from decimal import Decimal
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, field_validator
8
+
9
+
10
+ class Transaction(BaseModel):
11
+ """Financial transaction model.
12
+
13
+ Uses Decimal for amount to prevent floating-point precision errors
14
+ in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
15
+ """
16
+ id: str
17
+ account_id: str
18
+ date: date
19
+ amount: Decimal
20
+ currency: str = "USD"
21
+ description: Optional[str] = None
22
+ category: Optional[str] = None
23
+
24
+ @field_validator("amount", mode="before")
25
+ @classmethod
26
+ def _coerce_amount_to_decimal(cls, v):
27
+ """Coerce float/int to Decimal for backwards compatibility."""
28
+ if isinstance(v, (int, float)):
29
+ return Decimal(str(v))
30
+ return v
@@ -100,18 +100,27 @@ def calculate_net_worth(
100
100
 
101
101
  Returns:
102
102
  Net worth in base currency
103
+
104
+ Raises:
105
+ ValueError: If assets or liabilities contain non-base currencies and no
106
+ exchange rate conversion is available. This prevents silent data loss.
103
107
  """
108
+ import logging
109
+ logger = logging.getLogger(__name__)
110
+
111
+ # Collect any non-base currency items for error reporting
112
+ non_base_assets: list[tuple[str, str, float]] = []
113
+ non_base_liabilities: list[tuple[str, str, float]] = []
114
+
104
115
  # Sum all assets (use market_value if available, otherwise balance)
105
116
  total_assets = 0.0
106
117
  for asset in assets:
107
118
  # Use market value for investments/crypto (includes unrealized gains)
108
119
  amount = asset.market_value if asset.market_value is not None else asset.balance
109
120
 
110
- # Normalize to base currency (future: implement currency conversion)
111
- # For now, assume all are in USD
121
+ # Check for non-base currency
112
122
  if asset.currency != base_currency:
113
- # TODO: Implement currency conversion with exchange rate API
114
- # For V1, we'll require all accounts in USD or skip non-USD
123
+ non_base_assets.append((asset.name or asset.account_id, asset.currency, amount))
115
124
  continue
116
125
 
117
126
  total_assets += amount
@@ -119,13 +128,30 @@ def calculate_net_worth(
119
128
  # Sum all liabilities
120
129
  total_liabilities = 0.0
121
130
  for liability in liabilities:
122
- # Normalize to base currency
131
+ # Check for non-base currency
123
132
  if liability.currency != base_currency:
124
- # TODO: Implement currency conversion
133
+ non_base_liabilities.append((liability.name or liability.account_id, liability.currency, liability.balance))
125
134
  continue
126
135
 
127
136
  total_liabilities += liability.balance
128
137
 
138
+ # If any non-base currency items were found, log warning and raise error
139
+ # This prevents silent data loss where user's net worth is wrong
140
+ if non_base_assets or non_base_liabilities:
141
+ items_msg = []
142
+ if non_base_assets:
143
+ items_msg.append(f"Assets: {non_base_assets}")
144
+ if non_base_liabilities:
145
+ items_msg.append(f"Liabilities: {non_base_liabilities}")
146
+
147
+ error_msg = (
148
+ f"Cannot calculate net worth: found accounts in non-{base_currency} currencies. "
149
+ f"Currency conversion not yet implemented. {'; '.join(items_msg)}. "
150
+ f"Either convert all accounts to {base_currency} or wait for currency conversion feature."
151
+ )
152
+ logger.warning(error_msg)
153
+ raise ValueError(error_msg)
154
+
129
155
  return total_assets - total_liabilities
130
156
 
131
157
 
@@ -54,12 +54,20 @@ class PlaidClient(BankingProvider):
54
54
 
55
55
  # Map environment string to Plaid Environment enum
56
56
  # Note: Plaid only has Sandbox and Production (no Development in SDK)
57
+ env_str = environment or "sandbox"
57
58
  env_map = {
58
59
  "sandbox": plaid.Environment.Sandbox,
59
- "development": plaid.Environment.Sandbox, # Map development to sandbox
60
+ "development": plaid.Environment.Sandbox, # Map development to sandbox (Plaid SDK limitation)
60
61
  "production": plaid.Environment.Production,
61
62
  }
62
- host = env_map.get(environment or "sandbox", plaid.Environment.Sandbox)
63
+
64
+ if env_str not in env_map:
65
+ raise ValueError(
66
+ f"Invalid Plaid environment: '{env_str}'. "
67
+ f"Must be one of: sandbox, development, production"
68
+ )
69
+
70
+ host = env_map[env_str]
63
71
 
64
72
  # Configure Plaid client (v8.0.0+ API)
65
73
  configuration = plaid.Configuration(