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:
parent
b6b9c1df8f
commit
1a60fd4709
33 changed files with 551 additions and 875 deletions
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable, Provided
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
9
app/models/account/transaction/provideable.rb
Normal file
9
app/models/account/transaction/provideable.rb
Normal 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
|
13
app/models/account/transaction/provided.rb
Normal file
13
app/models/account/transaction/provided.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
|
|
15
app/models/exchange_rate/provideable.rb
Normal file
15
app/models/exchange_rate/provideable.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
11
app/models/financial_assistant.rb
Normal file
11
app/models/financial_assistant.rb
Normal 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
|
13
app/models/financial_assistant/provided.rb
Normal file
13
app/models/financial_assistant/provided.rb
Normal 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
|
|
@ -1,6 +1,4 @@
|
|||
class PlaidAccount < ApplicationRecord
|
||||
include Plaidable
|
||||
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
50
app/models/provider.rb
Normal 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
|
|
@ -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
|
|
@ -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
107
app/models/providers.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
31
app/models/security/provideable.rb
Normal file
31
app/models/security/provideable.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_column :security_prices, :ticker
|
||||
end
|
||||
end
|
30
db/schema.rb
generated
30
db/schema.rb
generated
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue