1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

Simplification of providers interface

This commit is contained in:
Zach Gollwitzer 2025-03-15 17:42:20 -04:00
parent b6b9c1df8f
commit 1a60fd4709
33 changed files with 551 additions and 875 deletions

View file

@ -5,7 +5,7 @@ class Settings::HostingsController < ApplicationController
before_action :ensure_admin, only: :clear_cache
def show
@synth_usage = Current.family.synth_usage
@synth_usage = Providers.synth.usage
end
def update

View file

@ -35,7 +35,7 @@ class Account::DataEnricher
candidates.each do |entry|
begin
info = entry.fetch_enrichment_info
info = entry.account_transaction.fetch_enrichment_info
next unless info.present?

View file

@ -1,5 +1,5 @@
class Account::Entry < ApplicationRecord
include Monetizable, Provided
include Monetizable
monetize :amount

View file

@ -1,11 +0,0 @@
module Account::Entry::Provided
extend ActiveSupport::Concern
include Synthable
def fetch_enrichment_info
return nil unless synth_client.present?
synth_client.enrich_transaction(name).info
end
end

View file

@ -1,5 +1,5 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable, Transferable
include Account::Entryable, Transferable, Provided
belongs_to :category, optional: true
belongs_to :merchant, optional: true

View file

@ -0,0 +1,9 @@
module Account::Transaction::Provideable
extend ActiveSupport::Concern
EnrichmentData = Data.define(:name, :icon_url, :category)
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
raise "Provider must implement #enrich_transaction"
end
end

View file

@ -0,0 +1,13 @@
module Account::Transaction::Provided
extend ActiveSupport::Concern
def fetch_enrichment_info
return nil unless Providers.synth # Only Synth can provide this data
Providers.synth.enrich_transaction(
entry.name,
amount: entry.amount,
date: entry.date
)
end
end

View file

@ -1,37 +0,0 @@
module Synthable
extend ActiveSupport::Concern
class_methods do
def synth_usage
synth_client&.usage
end
def synth_overage?
synth_usage&.usage&.utilization.to_i >= 100
end
def synth_healthy?
synth_client&.healthy?
end
def synth_client
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
end
def synth_client
self.class.synth_client
end
def synth_usage
self.class.synth_usage
end
def synth_overage?
self.class.synth_overage?
end
end

View file

@ -2,27 +2,4 @@ class ExchangeRate < ApplicationRecord
include Provided
validates :from_currency, :to_currency, :date, :rate, presence: true
class << self
def find_rate(from:, to:, date:, cache: true)
result = find_by \
from_currency: from,
to_currency: to,
date: date
result || fetch_rate_from_provider(from:, to:, date:, cache:)
end
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a
existing_dates = rates.map(&:date)
missing_dates = all_dates - existing_dates
if missing_dates.any?
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
end
rates
end
end
end

View file

@ -0,0 +1,15 @@
# Defines the interface an exchange rate provider must implement
module ExchangeRate::Provideable
extend ActiveSupport::Concern
FetchRateData = Data.define(:rate)
FetchRatesData = Data.define(:rates)
def fetch_rate(from:, to:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_rate"
end
def fetch_rates(from:, to:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_rates"
end
end

View file

@ -1,63 +1,13 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Synthable
class_methods do
def provider
synth_client
Providers.synth
end
private
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
return [] unless provider.present?
response = provider.fetch_exchange_rates \
from: from,
to: to,
start_date: start_date,
end_date: end_date
if response.success?
response.rates.map do |exchange_rate|
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
date: exchange_rate.dig(:date).to_date,
rate: exchange_rate.dig(:rate)
rate.save! if cache
rate
rescue ActiveRecord::RecordNotUnique
next
end
else
[]
end
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)
return nil unless provider.present?
response = provider.fetch_exchange_rate \
from: from,
to: to,
date: date
if response.success?
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
rate: response.rate,
date: date
if cache
rate.save! rescue ActiveRecord::RecordNotUnique
end
rate
else
nil
end
end
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
# TODO
end
end
end

View file

@ -1,5 +1,5 @@
class Family < ApplicationRecord
include Synthable, Plaidable, Syncable, AutoTransferMatchable
include Syncable, AutoTransferMatchable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@ -75,9 +75,9 @@ class Family < ApplicationRecord
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
provider = if region.to_sym == :eu
self.class.plaid_eu_provider
Providers.plaid_eu
else
self.class.plaid_us_provider
Providers.plaid_us
end
# early return when no provider

View file

@ -0,0 +1,11 @@
class FinancialAssistant
include Provided
def initialize(chat)
@chat = chat
end
def query(prompt, model_key: "gpt-4o")
llm_provider = self.class.llm_provider_for(model_key)
end
end

View file

@ -0,0 +1,13 @@
module FinancialAssistant::Provided
extend ActiveSupport::Concern
# Placeholder for AI chat PR
def llm_provider_for(model_key)
case model_key
when "gpt-4o"
Providers.openai
else
raise "Unknown LLM model key: #{model_key}"
end
end
end

View file

@ -1,6 +1,4 @@
class PlaidAccount < ApplicationRecord
include Plaidable
TYPE_MAPPING = {
"depository" => Depository,
"credit" => CreditCard,

View file

@ -1,5 +1,5 @@
class PlaidItem < ApplicationRecord
include Plaidable, Syncable
include Provided, Syncable
enum :plaid_region, { us: "us", eu: "eu" }
enum :status, { good: "good", requires_update: "requires_update" }, default: :good

View file

@ -1,13 +1,13 @@
module Plaidable
module PlaidItem::Provided
extend ActiveSupport::Concern
class_methods do
def plaid_us_provider
Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid
Providers.plaid_us
end
def plaid_eu_provider
Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu
Providers.plaid_eu
end
def plaid_provider_for_region(region)

50
app/models/provider.rb Normal file
View file

@ -0,0 +1,50 @@
class Provider
include Retryable
ProviderError = Class.new(StandardError)
def healthy?
raise NotImplementedError, "Subclasses must implement #healthy?"
end
def usage_percentage
raise NotImplementedError, "Subclasses must implement #usage"
end
def overage?
usage_percentage >= 100
end
private
# Generic response formats
Response = Data.define(:success?, :data, :error)
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
# Specific data payload formats
UsageData = Data.define(:used, :limit, :utilization, :plan)
# Subclasses can specify errors that can be retried
def retryable_errors
[]
end
def provider_response(retries: nil, &block)
data = if retries
retrying(retryable_errors, max_retries: retries) { yield }
else
yield
end
Response.new(
success?: true,
data: data,
error: nil,
)
rescue StandardError => error
Response.new(
success?: false,
data: nil,
error: error,
)
end
end

View file

@ -1,18 +0,0 @@
class Provider::Base
ProviderError = Class.new(StandardError)
TRANSIENT_NETWORK_ERRORS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
Faraday::ClientError,
Faraday::ServerError
]
class << self
def known_transient_errors
TRANSIENT_NETWORK_ERRORS + [ ProviderError ]
end
end
end

View file

@ -1,217 +1,210 @@
class Provider::Synth
include Retryable
class Provider::Synth < Provider
include ExchangeRate::Provideable
include Security::Provideable
include Account::Transaction::Provideable
def initialize(api_key)
@api_key = api_key
end
def healthy?
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
provider_response do
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
end
def usage
response = client.get("#{base_url}/user")
provider_response do
response = client.get("#{base_url}/user")
if response.status == 401
return UsageResponse.new(
success?: false,
error: "Unauthorized: Invalid API key",
raw_response: response
parsed = JSON.parse(response.body)
remaining = parsed.dig("api_calls_remaining")
limit = parsed.dig("api_limit")
used = limit - remaining
UsageData.new(
used: used,
limit: limit,
utilization: used.to_f / limit * 100,
plan: parsed.dig("plan"),
)
end
parsed = JSON.parse(response.body)
remaining = parsed.dig("api_calls_remaining")
limit = parsed.dig("api_limit")
used = limit - remaining
UsageResponse.new(
used: used,
limit: limit,
utilization: used.to_f / limit * 100,
plan: parsed.dig("plan"),
success?: true,
raw_response: response
)
rescue StandardError => error
UsageResponse.new(
success?: false,
error: error,
raw_response: error
)
end
def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil)
params = {
start_date: start_date,
end_date: end_date
}
def fetch_security_prices(ticker:, start_date:, end_date:, operating_mic_code: nil)
provider_response retries: 1 do
params = {
start_date: start_date,
end_date: end_date
}
params[:mic_code] = mic_code if mic_code.present?
params[:operating_mic_code] = operating_mic_code if operating_mic_code.present?
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
params
) do |body|
body.dig("prices").map do |price|
{
date: price.dig("date"),
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
currency: body.dig("currency") || "USD"
}
data = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
params
) do |body|
body.dig("prices")
end
end
SecurityPriceResponse.new \
prices: prices,
success?: true,
raw_response: prices.to_json
rescue StandardError => error
SecurityPriceResponse.new \
success?: false,
error: error,
raw_response: error
currency = data.first_page.dig("currency")
country_code = data.first_page.dig("exchange", "country_code")
exchange_mic = data.first_page.dig("exchange", "mic_code")
exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code")
Security::Price::Provideable::PricesData.new(
prices: data.paginated.map do |price|
Security::Price.new(
security: Security.new(
ticker: ticker,
country_code: country_code,
exchange_mic: exchange_mic,
exchange_operating_mic: exchange_operating_mic
),
date: price.dig("date"),
price: price.dig("close") || price.dig("open"),
currency: currency
)
end
)
end
end
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
provider_response retries: 2 do
response = client.get("#{base_url}/rates/historical") do |req|
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
end
if response.success?
ExchangeRateResponse.new \
rate: JSON.parse(response.body).dig("data", "rates", to),
success?: true,
raw_response: response
else
if on_last_attempt
ExchangeRateResponse.new \
success?: false,
error: build_error(response),
raw_response: response
else
raise build_error(response)
end
end
rates = JSON.parse(response.body).dig("data", "rates")
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new(
from_currency: from,
to_currency: to,
date: date,
rate: rates.dig(to)
)
)
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
exchange_rates = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data").map do |exchange_rate|
{
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
}
provider_response retries: 1 do
data = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data")
end
end
ExchangeRatesResponse.new \
rates: exchange_rates,
success?: true,
raw_response: exchange_rates.to_json
rescue StandardError => error
ExchangeRatesResponse.new \
success?: false,
error: error,
raw_response: error
ExchangeRate::Provideable::FetchRatesData.new(
rates: data.paginated.map do |exchange_rate|
ExchangeRate.new(
from_currency: from,
to_currency: to,
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
)
end
)
end
end
def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil)
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = query
req.params["dataset"] = dataset
req.params["country_code"] = country_code if country_code.present?
req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present?
req.params["limit"] = 25
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
return Security::Provideable::Search.new(securities: []) if symbol.blank? || symbol.length < 2
provider_response do
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = symbol
req.params["dataset"] = "limited"
req.params["country_code"] = country_code if country_code.present?
req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present?
req.params["limit"] = 25
end
parsed = JSON.parse(response.body)
Security::Provideable::Search.new(
securities: parsed.dig("data").map do |security|
Security.new(
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
country_code: security.dig("exchange", "country_code")
)
end
)
end
parsed = JSON.parse(response.body)
securities = parsed.dig("data").map do |security|
{
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
country_code: security.dig("exchange", "country_code")
}
end
SearchSecuritiesResponse.new \
securities: securities,
success?: true,
raw_response: response
end
def fetch_security_info(ticker:, mic_code: nil, operating_mic: nil)
response = client.get("#{base_url}/tickers/#{ticker}") do |req|
req.params["mic_code"] = mic_code if mic_code.present?
req.params["operating_mic"] = operating_mic if operating_mic.present?
provider_response do
response = client.get("#{base_url}/tickers/#{ticker}") do |req|
req.params["mic_code"] = mic_code if mic_code.present?
req.params["operating_mic"] = operating_mic if operating_mic.present?
end
data = JSON.parse(response.body).dig("data")
Security::Provideable::SecurityInfo.new(
ticker: ticker,
name: data.dig("name"),
links: data.dig("links"),
logo_url: data.dig("logo_url"),
description: data.dig("description"),
kind: data.dig("kind")
)
end
parsed = JSON.parse(response.body)
SecurityInfoResponse.new \
info: parsed.dig("data"),
success?: true,
raw_response: response
end
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
params = {
description: description,
amount: amount,
date: date,
city: city,
state: state,
country: country
}.compact
provider_response do
params = {
description: description,
amount: amount,
date: date,
city: city,
state: state,
country: country
}.compact
response = client.get("#{base_url}/enrich", params)
response = client.get("#{base_url}/enrich", params)
parsed = JSON.parse(response.body)
parsed = JSON.parse(response.body)
EnrichTransactionResponse.new \
info: EnrichTransactionInfo.new(
TransactionEnrichmentData.new(
name: parsed.dig("merchant"),
icon_url: parsed.dig("icon"),
category: parsed.dig("category")
),
success?: true,
raw_response: response
rescue StandardError => error
EnrichTransactionResponse.new \
success?: false,
error: error,
raw_response: error
)
end
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true
SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true
TransactionEnrichmentData = Data.define(:name, :icon_url, :category)
def retryable_errors
[
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
Faraday::ClientError,
Faraday::ServerError
]
end
def base_url
ENV["SYNTH_URL"] || "https://api.synthfinance.com"
@ -227,26 +220,15 @@ class Provider::Synth
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.response :raise_error
faraday.headers["Authorization"] = "Bearer #{api_key}"
faraday.headers["X-Source"] = app_name
faraday.headers["X-Source-Type"] = app_type
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
client.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page
end
client.get(url, params.merge(page: page))
end
def paginate(url, params = {})
@ -254,24 +236,26 @@ class Provider::Synth
page = 1
current_page = 0
total_pages = 1
first_page = nil
while current_page < total_pages
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
body = JSON.parse(response.body)
first_page = body unless first_page
page_results = yield(body)
results.concat(page_results)
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
page += 1
else
raise build_error(response)
end
page += 1
end
results
PaginatedData.new(
paginated: results,
first_page: first_page,
total_pages: total_pages
)
end
end

107
app/models/providers.rb Normal file
View file

@ -0,0 +1,107 @@
# Data Providers Overview
# ===========================================================================================
# The Maybe app utilizes multiple third-party data services. Since the app can
# be run in "self hosted" mode where users can provider API keys directly in
# the UI, providers must be instantiated at runtime; not an initializer.
#
# How data providers work:
# ===========================================================================================
#
# 1. Provided Concerns
# -------------------------------------------------------------------------------------------
# Every model that receives external data includes a `Provided` concern. This concern
# encapsulates how the model interacts with providers, keeping provider-specific logic
# separate from core model logic.
#
# The `Provided` concern can be as simple or complex as needed:
#
# Simple - Direct provider usage: for when data is specific to a provider
#
# module Transaction::Provided
# extend ActiveSupport::Concern
#
# def fetch_enrichment_info
# return unless Providers.synth
# Providers.synth.enrich_transaction(name, amount: amount)
# end
# end
#
# Complex - Provider selection with interface: for when data is generic and can be provided
# by any provider.
#
# module Security::Provided
# extend ActiveSupport::Concern
#
# class_methods do
# def provider
# Providers.synth
# end
# end
# end
#
# 2. Provideable Interfaces
# -------------------------------------------------------------------------------------------
# When a model represents a core "concept" that providers can implement (like exchange rates
# or security prices), we define a `Provideable` interface. This interface specifies the
# contract that concrete providers must fulfill to provide this type of data so that providers
# can be swapped out at runtime.
#
# Example:
# module ExchangeRate::Provideable
# extend ActiveSupport::Concern
#
# RatesResponse = Data.define(:success?, :rates)
#
# def fetch_rates(from:, to:, start_date:, end_date:)
# raise NotImplementedError
# end
# end
#
# Example "concept" provider components (exchange rates):
# -------------------------------------------------------------------------------------------
#
# app/models/
# exchange_rate.rb # <- ActiveRecord model and "concept"
# exchange_rate/
# provided.rb # <- Chooses the provider for this concept based on user settings / config
# provideable.rb # <- Defines interface for providing exchange rates
# provider.rb # <- Base provider class
# provider/
# synth.rb # <- Concrete provider implementation
#
# ===========================================================================================
module Providers
module_function
def synth
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
def plaid_us
config = Rails.application.config.plaid
return nil unless config.present?
Provider::Plaid.new(config, region: :us)
end
def plaid_eu
config = Rails.application.config.plaid_eu
return nil unless config.present?
Provider::Plaid.new(config, region: :eu)
end
def github
Provider::Github.new
end
def openai
# TODO: Placeholder for AI chat PR
end
end

View file

@ -1,33 +1,5 @@
class Security::Price < ApplicationRecord
include Provided
belongs_to :security
validates :price, :currency, presence: true
class << self
def find_price(security:, date:, cache: true)
result = find_by(security:, date:)
result || fetch_price_from_provider(security:, date:, cache:)
end
def find_prices(security:, start_date:, end_date: Date.current, cache: true)
prices = where(security_id: security.id, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = prices.map(&:date).to_set
missing_dates = (all_dates - existing_dates).sort
if missing_dates.any?
prices += fetch_prices_from_provider(
security: security,
start_date: missing_dates.first,
end_date: missing_dates.last,
cache: cache
)
end
prices
end
end
end

View file

@ -1,65 +0,0 @@
module Security::Price::Provided
extend ActiveSupport::Concern
include Synthable
class_methods do
def provider
synth_client
end
private
def fetch_price_from_provider(security:, date:, cache: false)
return nil unless provider.present?
return nil unless security.has_prices?
response = provider.fetch_security_prices \
ticker: security.ticker,
mic_code: security.exchange_operating_mic,
start_date: date,
end_date: date
if response.success? && response.prices.size > 0
price = Security::Price.new \
security: security,
date: response.prices.first[:date],
price: response.prices.first[:price],
currency: response.prices.first[:currency]
price.save! if cache
price
else
nil
end
end
def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false)
return [] unless provider.present?
return [] unless security
return [] unless security.has_prices?
response = provider.fetch_security_prices \
ticker: security.ticker,
mic_code: security.exchange_operating_mic,
start_date: start_date,
end_date: end_date
if response.success?
response.prices.map do |price|
new_price = Security::Price.find_or_initialize_by(
security: security,
date: price[:date]
) do |p|
p.price = price[:price]
p.currency = price[:currency]
end
new_price.save! if cache && new_price.new_record?
new_price
end
else
[]
end
end
end
end

View file

@ -0,0 +1,31 @@
module Security::Provideable
extend ActiveSupport::Concern
Search = Data.define(:securities)
PriceData = Data.define(:price)
PricesData = Data.define(:prices)
SecurityInfo = Data.define(
:ticker,
:name,
:links,
:logo_url,
:description,
:kind,
)
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
raise NotImplementedError, "Subclasses must implement #search_securities"
end
def fetch_security_info(ticker:, mic_code: nil, operating_mic: nil)
raise NotImplementedError, "Subclasses must implement #fetch_security_info"
end
def fetch_security_price(ticker:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_security_price"
end
def fetch_security_prices(ticker:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_security_prices"
end
end

View file

@ -1,28 +1,13 @@
module Security::Provided
extend ActiveSupport::Concern
include Synthable
class_methods do
def provider
synth_client
Providers.synth
end
def search_provider(query)
return [] if query[:search].blank? || query[:search].length < 2
response = provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country],
exchange_operating_mic: query[:exchange_operating_mic]
)
if response.success?
response.securities.map { |attrs| new(**attrs) }
else
[]
end
def sync_provider_prices(security:, start_date:, end_date: Date.current)
# TODO
end
end
end

View file

@ -4,11 +4,7 @@ module Upgrader::Provided
class_methods do
private
def fetch_latest_upgrade_candidates_from_provider
git_repository_provider.fetch_latest_upgrade_candidates
end
def git_repository_provider
Provider::Github.new
Providers.github.fetch_latest_upgrade_candidates
end
end
end

View file

@ -1,6 +1,6 @@
<%# locals: (family:) %>
<% if family.requires_data_provider? && family.synth_client.nil? %>
<% if family.requires_data_provider? && Providers.synth.nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">

View file

@ -0,0 +1,5 @@
class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2]
def change
remove_column :security_prices, :ticker
end
end

30
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
ActiveRecord::Schema[7.2].define(version: 2025_03_15_191233) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -196,6 +196,15 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.index ["family_id"], name: "index_categories_on_family_id"
end
create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.string "title", null: false
t.string "instructions"
t.index ["user_id"], name: "index_chats_on_user_id"
end
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -481,6 +490,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.index ["family_id"], name: "index_merchants_on_family_id"
end
create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "chat_id", null: false
t.text "openai_id"
t.string "role", default: "user", null: false
t.string "message_type", default: "text", null: false
t.text "content", null: false
t.index ["chat_id"], name: "index_messages_on_chat_id"
end
create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -560,7 +580,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "ticker"
t.date "date"
t.decimal "price", precision: 19, scale: 4
t.string "currency", default: "USD"
@ -676,8 +695,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.string "otp_backup_codes", default: [], array: true
t.boolean "show_sidebar", default: true
t.string "default_period", default: "last_30_days", null: false
t.uuid "last_viewed_chat_id"
t.boolean "show_ai_sidebar", default: true
t.boolean "ai_enabled", default: false, null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
end
@ -708,6 +731,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
@ -716,6 +740,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
add_foreign_key "invitations", "families"
add_foreign_key "invitations", "users", column: "inviter_id"
add_foreign_key "merchants", "families"
add_foreign_key "messages", "chats"
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id"
@ -727,5 +752,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
add_foreign_key "tags", "families"
add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id", on_delete: :cascade
add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id", on_delete: :cascade
add_foreign_key "users", "chats", column: "last_viewed_chat_id"
add_foreign_key "users", "families"
end

View file

@ -17,7 +17,9 @@ class Provider::SynthTest < ActiveSupport::TestCase
end_date: Date.iso8601("2024-08-01")
)
assert 213, response.size
puts response
assert 213, response
end
end

View file

@ -1,69 +0,0 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday v2.10.0
Authorization:
- Bearer <SYNTH_API_KEY>
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Thu, 01 Aug 2024 17:20:28 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cf-Ray:
- 8ac77fbcc9d013ae-CMH
Cf-Cache-Status:
- DYNAMIC
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"668c8ac287a5ff6d6a705c35c69823b1"
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- ff56c2fe-6252-4b2c
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 61992b01-969b-4af5-8119-9b17e385da07
X-Runtime:
- '0.369358'
X-Xss-Protection:
- '0'
Server:
- cloudflare
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: '{"data":{"date":"2024-08-01","source":"USD","rates":{"MXN":18.645877}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":248999}}'
recorded_at: Thu, 01 Aug 2024 17:20:28 GMT
recorded_with: VCR 6.2.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long