1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Data provider simplification, tests, and documentation (#1997)

* Ignore env.test from source control

* Simplification of providers interface

* Synth tests

* Update money to use new find rates method

* Remove unused issues code

* Additional issue feature removals

* Update price data fetching and tests

* Update documentation for providers

* Security test fixes

* Fix self host test

* Update synth usage data access

* Remove AI pr schema changes
This commit is contained in:
Zach Gollwitzer 2025-03-17 11:54:53 -04:00 committed by GitHub
parent dd75cadebc
commit f65b93a352
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 2014 additions and 1638 deletions

View file

@ -1,6 +1,7 @@
---
description: This rule explains the system architecture and data flow of the Rails app
globs: *
alwaysApply: false
---
This file outlines how the codebase is structured and how data flows through the app.
@ -131,4 +132,177 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
## Data Providers
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
```rb
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
end
```
There are two types of 3rd party data in the Maybe app:
1. "Concept" data
2. One-off data
### "Concept" data
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this:
```
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
```
Where the `Provideable` and concrete provider implementations would be something like:
```rb
# 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_exchange_rate(from:, to:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
end
end
```
Any provider that is a valid exchange rate provider must implement this interface:
```rb
class ConcreteProvider < Provider
include ExchangeRate::Provideable
def fetch_exchange_rate(from:, to:, date:)
provider_response do
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new # build response
)
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
# Implementation
end
end
```
### One-off data
For data that does not fit neatly into a "concept", a `Provideable` is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
```rb
class SomeModel < Application
def synth_usage
Providers.synth.usage
end
end
```
## "Provided" Concerns
In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
- Choosing the provider to use for this "concept"
- Providing convenience methods on the model for accessing data
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
```rb
module ExchangeRate::Provided
extend ActiveSupport::Concern
class_methods do
def provider
Providers.synth
end
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
# Implementation
end
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
# Implementation
end
end
end
```
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
```rb
def access_patterns_example
# Call exchange rate provider directly
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
# Call convenience method
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
end
```
## Concrete provider implementations
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object:
```rb
class ConcreteProvider < Provider
def fetch_some_data
provider_response do
ExampleData.new(
example: "data"
)
end
end
end
```
The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
```rb
class ConcreteProvider < Provider
def fetch_some_data
provider_response do
data = nil
# Raise an error if data cannot be returned
raise ProviderError.new("Could not find the data you need") if data.nil?
data
end
end
end
```

View file

@ -1,8 +0,0 @@
SELF_HOSTED=false
SYNTH_API_KEY=fookey
# Set to true if you want SimpleCov reports generated
COVERAGE=false
# Set to true to run test suite serially
DISABLE_PARALLELIZATION=false

View file

@ -1,3 +1,5 @@
SELF_HOSTED=false
# ================
# Data Providers
# ---------------------------------------------------------------------------------

1
.gitignore vendored
View file

@ -11,7 +11,6 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
!.env.test
!.env*.example
# Ignore all logfiles and tempfiles.

View file

@ -1,20 +0,0 @@
class Issue::ExchangeRateProviderMissingsController < ApplicationController
before_action :set_issue, only: :update
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
account = @issue.issuable
account.sync_later
redirect_back_or_to account
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
def exchange_rate_params
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
end
end

View file

@ -1,13 +0,0 @@
class IssuesController < ApplicationController
before_action :set_issue, only: :show
def show
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
end

View file

@ -1,8 +1,8 @@
class SecuritiesController < ApplicationController
def index
@securities = Security.search_provider({
search: params[:q],
country: params[:country_code] == "US" ? "US" : nil
})
@securities = Security.search_provider(
params[:q],
country_code: params[:country_code] == "US" ? "US" : nil
)
end
end

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

@ -2,7 +2,6 @@ class EnrichTransactionBatchJob < ApplicationJob
queue_as :latency_high
def perform(account, batch_size = 100, offset = 0)
enricher = Account::DataEnricher.new(account)
enricher.enrich_transaction_batch(batch_size, offset)
account.enrich_transaction_batch(batch_size, offset)
end
end

View file

@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible
include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible
validates :name, :balance, :currency, presence: true
@ -13,7 +13,6 @@ class Account < ApplicationRecord
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
monetize :balance, :cash_balance
@ -88,7 +87,6 @@ class Account < ApplicationRecord
def post_sync
broadcast_remove_to(family, target: "syncing-notice")
resolve_stale_issues
accountable.post_sync
end

View file

@ -7,14 +7,13 @@ module Account::Convertible
return
end
rates = ExchangeRate.find_rates(
affected_row_count = ExchangeRate.sync_provider_rates(
from: currency,
to: target_currency,
start_date: start_date,
cache: true # caches from provider to DB
)
Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}")
Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}")
end
private

View file

@ -1,66 +0,0 @@
class Account::DataEnricher
attr_reader :account
def initialize(account)
@account = account
end
def run
total_unenriched = account.entries.account_transactions
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.count
if total_unenriched > 0
batch_size = 50
batches = (total_unenriched.to_f / batch_size).ceil
batches.times do |batch|
EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size)
end
end
end
def enrich_transaction_batch(batch_size = 50, offset = 0)
candidates = account.entries.account_transactions
.includes(entryable: [ :merchant, :category ])
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.offset(offset)
.limit(batch_size)
Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})")
merchants = {}
candidates.each do |entry|
begin
info = entry.fetch_enrichment_info
next unless info.present?
if info.name.present?
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
if info.icon_url.present?
merchant.icon_url = info.icon_url
end
end
entryable_attributes = { id: entry.entryable_id }
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
Account.transaction do
merchant.save! if merchant.present?
entry.update!(
enriched_at: Time.current,
enriched_name: info.name,
entryable_attributes: entryable_attributes
)
end
rescue => e
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
end
end
end
end

View file

@ -2,11 +2,70 @@ module Account::Enrichable
extend ActiveSupport::Concern
def enrich_data
DataEnricher.new(self).run
total_unenriched = entries.account_transactions
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.count
if total_unenriched > 0
batch_size = 50
batches = (total_unenriched.to_f / batch_size).ceil
batches.times do |batch|
EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size)
# EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size)
end
end
end
def enrich_transaction_batch(batch_size = 50, offset = 0)
transactions_batch = enrichable_transactions.offset(offset).limit(batch_size)
Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})")
merchants = {}
transactions_batch.each do |transaction|
begin
info = transaction.fetch_enrichment_info
next unless info.present?
if info.name.present?
merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name)
if info.icon_url.present?
merchant.icon_url = info.icon_url
end
end
Account.transaction do
merchant.save! if merchant.present?
transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil?
transaction.entry.update!(
enriched_at: Time.current,
enriched_name: info.name,
)
end
rescue => e
Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}")
end
end
end
private
def enrichable?
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
end
def enrichable_transactions
transactions.active
.includes(:merchant, :category)
.where(
"account_entries.enriched_at IS NULL",
"OR merchant_id IS NULL",
"OR category_id IS NULL"
)
end
end

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

@ -79,12 +79,11 @@ class Account::Holding::PortfolioCache
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
# Highest priority prices
db_or_provider_prices = Security::Price.find_prices(
security: security,
start_date: account.start_date,
end_date: Date.current
).map do |price|
# Load prices from provider to DB
security.sync_provider_prices(start_date: account.start_date)
# High priority prices from DB (synced from provider)
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
PriceWithPriority.new(
price: price,
priority: 1
@ -125,7 +124,7 @@ class Account::Holding::PortfolioCache
@security_cache[security.id] = {
security: security,
prices: db_or_provider_prices + trade_prices + holding_prices
prices: db_prices + trade_prices + holding_prices
}
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,15 @@
module Account::Transaction::Provided
extend ActiveSupport::Concern
def fetch_enrichment_info
return nil unless Providers.synth # Only Synth can provide this data
response = Providers.synth.enrich_transaction(
entry.name,
amount: entry.amount,
date: entry.date
)
response.data
end
end

View file

@ -1,52 +0,0 @@
module Issuable
extend ActiveSupport::Concern
included do
has_many :issues, dependent: :destroy, as: :issuable
end
def has_issues?
issues.active.any?
end
def resolve_stale_issues
issues.active.each do |issue|
issue.resolve! if issue.stale?
end
end
def observe_unknown_issue(error)
observe_issue(
Issue::Unknown.new(data: { error: error.message })
)
end
def observe_missing_exchange_rates(from:, to:, dates:)
observe_issue(
Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates })
)
end
def observe_missing_exchange_rate_provider
observe_issue(
Issue::ExchangeRateProviderMissing.new
)
end
def highest_priority_issue
issues.active.ordered.first
end
private
def observe_issue(new_issue)
existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil)
if existing_issue
existing_issue.update!(last_observed_at: Time.current, data: new_issue.data)
else
new_issue.issuable = self
new_issue.save!
end
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,5 @@ 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
validates :date, uniqueness: { scope: %i[from_currency to_currency] }
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_exchange_rate(from:, to:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
end
end

View file

@ -1,63 +1,44 @@
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?
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
rate = find_by(from_currency: from, to_currency: to, date: date)
return rate if rate.present?
response = provider.fetch_exchange_rates \
from: from,
to: to,
start_date: start_date,
end_date: end_date
return nil unless provider.present? # No provider configured (some self-hosted apps)
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)
response = provider.fetch_exchange_rate(from: from, to: to, date: date)
return nil unless response.success? # Provider error
rate = response.data.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
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
unless provider.present?
Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates")
return 0
end
rate
else
nil
fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date)
unless fetched_rates.success?
Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}")
return 0
end
rates_data = fetched_rates.data.rates.map do |rate|
rate.attributes.slice("from_currency", "to_currency", "date", "rate")
end
ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date])
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" ],
@ -19,7 +19,6 @@ class Family < ApplicationRecord
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :issues, through: :accounts
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
@ -75,9 +74,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,35 +0,0 @@
class Issue < ApplicationRecord
belongs_to :issuable, polymorphic: true
after_initialize :set_default_severity
enum :severity, { critical: 1, error: 2, warning: 3, info: 4 }
validates :severity, presence: true
scope :active, -> { where(resolved_at: nil) }
scope :ordered, -> { order(:severity) }
def title
model_name.human
end
# The conditions that must be met for an issue to be fixed
def stale?
raise NotImplementedError, "#{self.class} must implement #{__method__}"
end
def resolve!
update!(resolved_at: Time.current)
end
def default_severity
:warning
end
private
def set_default_severity
self.severity ||= default_severity
end
end

View file

@ -1,9 +0,0 @@
class Issue::ExchangeRateProviderMissing < Issue
def default_severity
:error
end
def stale?
ExchangeRate.provider_healthy?
end
end

View file

@ -1,15 +0,0 @@
class Issue::ExchangeRatesMissing < Issue
store_accessor :data, :from_currency, :to_currency, :dates
validates :from_currency, :to_currency, :dates, presence: true
def stale?
if dates.length == 1
ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present?
else
sorted_dates = dates.sort
rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last)
rates.length == dates.length
end
end
end

View file

@ -1,11 +0,0 @@
class Issue::Unknown < Issue
def default_severity
:warning
end
# Unknown issues are always stale because we only want to show them
# to the user once. If the same error occurs again, we'll create a new instance.
def stale?
true
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)

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

@ -0,0 +1,35 @@
class Provider
include Retryable
ProviderError = Class.new(StandardError)
ProviderResponse = Data.define(:success?, :data, :error)
private
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
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
ProviderResponse.new(
success?: true,
data: data,
error: nil,
)
rescue StandardError => error
ProviderResponse.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,137 +1,96 @@
class Provider::Synth
include Retryable
class Provider::Synth < Provider
include ExchangeRate::Provideable
include Security::Provideable
def initialize(api_key)
@api_key = api_key
end
def healthy?
provider_response do
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
end
def usage
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
)
end
parsed = JSON.parse(response.body)
remaining = parsed.dig("api_calls_remaining")
limit = parsed.dig("api_limit")
used = limit - remaining
UsageResponse.new(
UsageData.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
}
params[:mic_code] = mic_code if 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"
}
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
end
# ================================
# Exchange Rates
# ================================
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(
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").map do |exchange_rate|
{
body.dig("data")
end
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
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
end
# ================================
# Securities
# ================================
def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil)
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
provider_response do
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = query
req.params["dataset"] = dataset
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
@ -139,8 +98,9 @@ class Provider::Synth
parsed = JSON.parse(response.body)
securities = parsed.dig("data").map do |security|
{
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"),
@ -148,30 +108,84 @@ class Provider::Synth
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
end
def fetch_security_info(security)
provider_response do
response = client.get("#{base_url}/tickers/#{security.ticker}") do |req|
req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present?
req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present?
end
data = JSON.parse(response.body).dig("data")
Security::Provideable::SecurityInfo.new(
ticker: security.ticker,
name: data.dig("name"),
links: data.dig("links"),
logo_url: data.dig("logo_url"),
description: data.dig("description"),
kind: data.dig("kind")
)
end
end
def fetch_security_price(security, date:)
provider_response do
historical_data = fetch_security_prices(security, start_date: date, end_date: date)
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty?
Security::Provideable::PriceData.new(
price: historical_data.data.prices.first
)
end
end
def fetch_security_prices(security, start_date:, end_date:)
provider_response retries: 1 do
params = {
start_date: start_date,
end_date: end_date
}
params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present?
data = paginate(
"#{base_url}/tickers/#{security.ticker}/open-close",
params
) do |body|
body.dig("prices")
end
SearchSecuritiesResponse.new \
securities: securities,
success?: true,
raw_response: response
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::Provideable::PricesData.new(
prices: data.paginated.map do |price|
Security::Price.new(
security: security,
date: price.dig("date"),
price: price.dig("close") || price.dig("open"),
currency: currency
)
end
)
end
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?
end
parsed = JSON.parse(response.body)
SecurityInfoResponse.new \
info: parsed.dig("data"),
success?: true,
raw_response: response
end
# ================================
# Transactions
# ================================
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
provider_response do
params = {
description: description,
amount: amount,
@ -185,33 +199,28 @@ class Provider::Synth
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 +236,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,12 +252,13 @@ 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)
first_page = body unless first_page
page_results = yield(body)
results.concat(page_results)
@ -267,11 +266,12 @@ class Provider::Synth
total_pages = body.dig("paging", "total_pages")
page += 1
else
raise build_error(response)
end
end
results
PaginatedData.new(
paginated: results,
first_page: first_page,
total_pages: total_pages
)
end
end

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

@ -0,0 +1,35 @@
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

@ -10,7 +10,7 @@ class Security < ApplicationRecord
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
@current_price ||= find_or_fetch_price
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end

View file

@ -1,33 +1,6 @@
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
validates :date, :price, :currency, presence: true
validates :date, uniqueness: { scope: %i[security_id currency] }
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(security)
raise NotImplementedError, "Subclasses must implement #fetch_security_info"
end
def fetch_security_price(security, date:)
raise NotImplementedError, "Subclasses must implement #fetch_security_price"
end
def fetch_security_prices(security, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_security_prices"
end
end

View file

@ -1,28 +1,65 @@
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
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
return [] if symbol.blank? || symbol.length < 2
response = provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country],
exchange_operating_mic: query[:exchange_operating_mic]
)
response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic)
if response.success?
response.securities.map { |attrs| new(**attrs) }
response.data.securities
else
[]
end
end
end
def sync_provider_prices(start_date:, end_date: Date.current)
unless has_prices?
Rails.logger.warn("Security id=#{id} ticker=#{ticker} is not known by provider, skipping price sync")
return 0
end
unless provider.present?
Rails.logger.warn("No security provider configured, cannot sync prices for id=#{id} ticker=#{ticker}")
return 0
end
response = provider.fetch_security_prices(self, start_date: start_date, end_date: end_date)
unless response.success?
Rails.logger.error("Provider error for sync_provider_prices with id=#{id} ticker=#{ticker}: #{response.error}")
return 0
end
fetched_prices = response.data.prices.map do |price|
price.attributes.slice("security_id", "date", "price", "currency")
end
Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency])
end
def find_or_fetch_price(date: Date.current, cache: true)
price = prices.find_by(date: date)
return price if price.present?
response = provider.fetch_security_price(self, date: date)
return nil unless response.success? # Provider error
price = response.data.price
price.save! if cache
price
end
private
def provider
self.class.provider
end
end

View file

@ -97,7 +97,7 @@ class TradeImport < Import
provider_security = @provider_securities_cache[cache_key] ||= begin
Security.search_provider(
query: ticker,
ticker,
exchange_operating_mic: exchange_operating_mic
).first
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

@ -37,7 +37,7 @@
required: true %>
</div>
<% else %>
<%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %>
<%= form.text_field :manual_ticker, label: "Ticker symbol", placeholder: "AAPL", required: true %>
<% end %>
<% end %>

View file

@ -17,13 +17,6 @@
</p>
<% else %>
<%= link_to account.name, account, class: [(account.is_active ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<% if account.has_issues? %>
<div class="text-sm flex items-center gap-1 text-error">
<%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %>
<%= tag.span t(".has_issues") %>
<%= link_to t(".troubleshoot"), issue_path(account.issues.first), class: "underline", data: { turbo_frame: :drawer } %>
</div>
<% end %>
<% end %>
</div>

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

@ -35,8 +35,4 @@
<%= render "accounts/show/menu", account: account %>
</div>
</div>
<% if account.highest_priority_issue %>
<%= render partial: "issues/issue", locals: { issue: account.highest_priority_issue } %>
<% end %>
</header>

View file

@ -1,12 +0,0 @@
<p>The Synth data provider could not find the requested data.</p>
<p>We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by
requesting the data you need.</p>
<p>Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the
following information:</p>
<ul>
<li>What type of data is missing?</li>
<li>Any other information you think might be helpful</li>
</ul>

View file

@ -1,28 +0,0 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>You have set your family currency preference to <%= Current.family.currency %>. <%= @issue.issuable.name %> has
entries in another currency, which means we have to fetch exchange rates from a data provider to accurately show
historical results.</p>
<p>We have detected that your exchange rates provider is not configured yet.</p>
<% end %>
<%= content_for :action do %>
<% if self_hosted? %>
<p>To fix this issue, you need to provide an API key for your exchange rate provider.</p>
<p>Currently, we support <%= link_to "Synth Finance", "https://synthfinance.com", target: "_blank" %>, so you need
to
get a free API key from the link provided.</p>
<p>Once you have your API key, paste it below to configure it.</p>
<%= styled_form_with model: @issue, url: issue_exchange_rate_provider_missing_path(@issue), method: :patch, class: "space-y-3" do |form| %>
<%= form.text_field :synth_api_key, label: "Synth API Key", placeholder: "Synth API Key", type: "password", class: "w-full", value: Setting.synth_api_key %>
<%= form.submit "Save and Re-Sync Account", class: "btn-primary" %>
<% end %>
<% else %>
<p>Please contact the Maybe team.</p>
<% end %>
<% end %>

View file

@ -1,11 +0,0 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>Some exchange rates are missing for this account.</p>
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
<% end %>
<%= content_for :action do %>
<%= render "issue/request_synth_data_action" %>
<% end %>

View file

@ -1,11 +0,0 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>Some stock prices are missing for this account.</p>
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
<% end %>
<%= content_for :action do %>
<%= render "issue/request_synth_data_action" %>
<% end %>

View file

@ -1,23 +0,0 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>An unknown issue has occurred.</p>
<pre><code><%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %></code></pre>
<% end %>
<%= content_for :action do %>
<p>There is no fix for this issue yet.</p>
<p>Maybe is in active development and we value your feedback. There are a couple ways you can report this issue to
help us make Maybe better:</p>
<ul>
<li>Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %></li>
<li>Open an issue on
our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %></li>
</ul>
<p>If there is data shown in the code block above that you think might be helpful, please include it in your
report.</p>
<% end %>

View file

@ -1,15 +0,0 @@
<%# locals: (issue:) %>
<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %>
<% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %>
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %>
<div class="flex gap-3 items-center grow <%= text_class %>">
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
<p class="text-sm break-words"><%= issue.title %></p>
</div>
<div class="flex items-center gap-4 ml-auto">
<%= link_to "Troubleshoot", issue_path(issue), class: "#{text_class} font-medium hover:underline", data: { turbo_frame: :drawer } %>
</div>
<% end %>

View file

@ -1,15 +0,0 @@
<%= render "layouts/shared/htmldoc" do %>
<%= drawer do %>
<article class="prose">
<%= tag.h2 do %>
<%= yield :title %>
<% end %>
<%= tag.h3 "Issue Description" %>
<%= yield :description %>
<%= tag.h3 "How to fix this issue" %>
<%= yield :action %>
</article>
<% end %>
<% end %>

View file

@ -30,18 +30,18 @@
<div class="space-y-2">
<p class="text-sm text-secondary">
<%= t(".api_calls_used",
used: number_with_delimiter(@synth_usage.used),
limit: number_with_delimiter(@synth_usage.limit),
percentage: number_to_percentage(@synth_usage.utilization, precision: 1)) %>
used: number_with_delimiter(@synth_usage.data.used),
limit: number_with_delimiter(@synth_usage.data.limit),
percentage: number_to_percentage(@synth_usage.data.utilization, precision: 1)) %>
</p>
<div class="w-52 h-1.5 bg-gray-100 rounded-2xl">
<div class="h-full bg-green-500 rounded-2xl"
style="width: <%= [@synth_usage.utilization, 2].max %>%;"></div>
style="width: <%= [@synth_usage.data.utilization, 2].max %>%;"></div>
</div>
</div>
<div class="bg-gray-100 rounded-md px-1.5 py-0.5 w-fit">
<p class="text-xs font-medium text-secondary uppercase">
<%= t(".plan", plan: @synth_usage.plan) %>
<%= t(".plan", plan: @synth_usage.data.plan) %>
</p>
</div>
</div>

View file

@ -1,28 +1,5 @@
{
"ignored_warnings": [
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "03a2010b605b8bdb7d4e1566720904d69ef2fbf8e7bc35735b84e161b475215e",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/controllers/issues_controller.rb",
"line": 5,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(template => \"#{Current.family.issues.find(params[:id]).class.name.underscore.pluralize}/show\", { :layout => \"issues\" })",
"render_path": null,
"location": {
"type": "method",
"class": "IssuesController",
"method": "show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
@ -30,7 +7,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/concerns/entryable_resource.rb",
"line": 122,
"line": 124,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:account_entry).permit(:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)",
"render_path": null,
@ -53,7 +30,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/invitations_controller.rb",
"line": 40,
"line": 58,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:invitation).permit(:email, :role)",
"render_path": null,
@ -76,7 +53,7 @@
"check_name": "CrossSiteScripting",
"message": "Unescaped model attribute",
"file": "app/views/pages/changelog.html.erb",
"line": 22,
"line": 18,
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
"code": "Provider::Github.new.fetch_latest_release_notes[:body]",
"render_path": [
@ -84,7 +61,7 @@
"type": "controller",
"class": "PagesController",
"method": "changelog",
"line": 35,
"line": 15,
"file": "app/controllers/pages_controller.rb",
"rendered": {
"name": "pages/changelog",
@ -134,7 +111,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/import/configurations/show.html.erb",
"line": 15,
"line": 34,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })",
"render_path": [

View file

@ -1,8 +0,0 @@
---
en:
activerecord:
models:
issue/exchange_rate_provider_missing: Exchange rate provider missing
issue/exchange_rates_missing: Exchange rates missing
issue/missing_prices: Missing prices
issue/unknown: Unknown issue occurred

View file

@ -2,7 +2,6 @@
en:
accounts:
account:
has_issues: Issue detected.
troubleshoot: Troubleshoot
chart:
no_change: no change

View file

@ -154,12 +154,6 @@ Rails.application.routes.draw do
resources :invite_codes, only: %i[index create]
resources :issues, only: :show
namespace :issue do
resources :exchange_rate_provider_missings, only: :update
end
resources :invitations, only: [ :new, :create, :destroy ] do
get :accept, on: :member
end

View file

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

View file

@ -0,0 +1,11 @@
class RemoveIssues < ActiveRecord::Migration[7.2]
def change
drop_table :issues do |t|
t.references :issuable, polymorphic: true, null: false
t.string :type, null: false
t.integer :severity, null: false
t.datetime :last_observed_at
t.datetime :resolved_at
end
end
end

View file

@ -0,0 +1,31 @@
class SecurityPriceUniqueIndex < ActiveRecord::Migration[7.2]
def change
# First, we have to delete duplicate prices from DB so we can apply the unique index
reversible do |dir|
dir.up do
execute <<~SQL
DELETE FROM security_prices
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY security_id, date, currency
ORDER BY updated_at DESC, id DESC
) as row_num
FROM security_prices
) as duplicates
WHERE row_num > 1
);
SQL
end
end
add_index :security_prices, [ :security_id, :date, :currency ], unique: true
change_column_null :security_prices, :date, false
change_column_null :security_prices, :price, false
change_column_null :security_prices, :currency, false
change_column_null :exchange_rates, :date, false
change_column_null :exchange_rates, :rate, false
end
end

27
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_16_122019) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -219,8 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "from_currency", null: false
t.string "to_currency", null: false
t.decimal "rate"
t.date "date"
t.decimal "rate", null: false
t.date "date", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
@ -449,19 +449,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.index ["token"], name: "index_invite_codes_on_token", unique: true
end
create_table "issues", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "issuable_type"
t.uuid "issuable_id"
t.string "type"
t.integer "severity"
t.datetime "last_observed_at"
t.datetime "resolved_at"
t.jsonb "data"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["issuable_type", "issuable_id"], name: "index_issues_on_issuable"
end
create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -560,13 +547,13 @@ 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"
t.date "date", null: false
t.decimal "price", precision: 19, scale: 4, null: false
t.string "currency", default: "USD", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "security_id"
t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true
t.index ["security_id"], name: "index_security_prices_on_security_id"
end

View file

@ -46,7 +46,7 @@ class Money
if iso_code == other_iso_code
self
else
exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate
exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate
raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate

View file

@ -1,19 +0,0 @@
require "test_helper"
class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@issue = issues(:one)
end
test "should update issue" do
patch issue_exchange_rate_provider_missing_url(@issue), params: {
issue_exchange_rate_provider_missing: {
synth_api_key: "1234"
}
}
assert_enqueued_with job: SyncJob
assert_redirected_to @issue.issuable
end
end

View file

@ -1,18 +0,0 @@
require "test_helper"
class IssuesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
end
test "should get show polymorphically" do
issues.each do |issue|
get issue_url(issue)
assert_response :success
assert_dom "h2", text: issue.title
assert_dom "h3", text: "Issue Description"
assert_dom "h3", text: "How to fix this issue"
end
end
end

View file

@ -1,8 +1,22 @@
require "test_helper"
require "ostruct"
class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
include ProviderTestHelper
setup do
sign_in users(:family_admin)
@provider = mock
Providers.stubs(:synth).returns(@provider)
@usage_response = provider_success_response(
OpenStruct.new(
used: 10,
limit: 100,
utilization: 10,
plan: "free",
)
)
end
test "cannot edit when self hosting is disabled" do
@ -16,6 +30,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
test "should get edit when self hosting is enabled" do
@provider.expects(:usage).returns(@usage_response)
with_self_hosting do
get settings_hosting_url
assert_response :success

View file

@ -1,5 +0,0 @@
one:
issuable: depository
issuable_type: Account
type: Issue::Unknown
last_observed_at: 2024-08-15 08:54:04

View file

@ -3,20 +3,34 @@ require "test_helper"
module ExchangeRateProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "exchange rate provider interface" do
assert_respond_to @subject, :healthy?
assert_respond_to @subject, :fetch_exchange_rate
assert_respond_to @subject, :fetch_exchange_rates
test "fetches single exchange rate" do
VCR.use_cassette("#{vcr_key_prefix}/exchange_rate") do
response = @subject.fetch_exchange_rate(
from: "USD",
to: "GBP",
date: Date.parse("01.01.2024")
)
rate = response.data.rate
assert_kind_of ExchangeRate, rate
assert_equal "USD", rate.from_currency
assert_equal "GBP", rate.to_currency
end
end
test "exchange rate provider response contract" do
VCR.use_cassette "synth/exchange_rate" do
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
test "fetches paginated exchange_rate historical data" do
VCR.use_cassette("#{vcr_key_prefix}/exchange_rates") do
response = @subject.fetch_exchange_rates(
from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024")
)
assert_respond_to response, :rate
assert_respond_to response, :success?
assert_respond_to response, :error
assert_respond_to response, :raw_response
assert 213, response.data.rates.count # 213 days between 01.01.2024 and 31.07.2024
end
end
private
def vcr_key_prefix
@subject.class.name.demodulize.underscore
end
end

View file

@ -1,26 +0,0 @@
require "test_helper"
module SecurityPriceProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "security price provider interface" do
assert_respond_to @subject, :healthy?
assert_respond_to @subject, :fetch_security_prices
end
test "security price provider response contract" do
VCR.use_cassette "synth/security_prices" do
response = @subject.fetch_security_prices(
ticker: "AAPL",
mic_code: "XNAS",
start_date: Date.iso8601("2024-01-01"),
end_date: Date.iso8601("2024-08-01")
)
assert_respond_to response, :prices
assert_respond_to response, :success?
assert_respond_to response, :error
assert_respond_to response, :raw_response
end
end
end

View file

@ -0,0 +1,62 @@
require "test_helper"
module SecurityProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "fetches security price" do
aapl = securities(:aapl)
VCR.use_cassette("#{vcr_key_prefix}/security_price") do
response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01"))
assert response.success?
assert response.data.price.present?
end
end
test "fetches paginated securities prices" do
aapl = securities(:aapl)
VCR.use_cassette("#{vcr_key_prefix}/security_prices") do
response = @subject.fetch_security_prices(
aapl,
start_date: Date.iso8601("2024-01-01"),
end_date: Date.iso8601("2024-08-01")
)
assert response.success?
assert 213, response.data.prices.count
end
end
test "searches securities" do
VCR.use_cassette("#{vcr_key_prefix}/security_search") do
response = @subject.search_securities("AAPL", country_code: "US")
securities = response.data.securities
assert securities.any?
security = securities.first
assert_kind_of Security, security
assert_equal "AAPL", security.ticker
end
end
test "fetches security info" do
aapl = securities(:aapl)
VCR.use_cassette("#{vcr_key_prefix}/security_info") do
response = @subject.fetch_security_info(aapl)
info = response.data
assert_equal "AAPL", info.ticker
assert_equal "Apple Inc.", info.name
assert info.logo_url.present?
assert_equal "common stock", info.kind
assert info.description.present?
end
end
private
def vcr_key_prefix
@subject.class.name.demodulize.underscore
end
end

View file

@ -91,13 +91,13 @@ class MoneyTest < ActiveSupport::TestCase
end
test "converts currency when rate available" do
ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2))
ExchangeRate.expects(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1.2))
assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur)
end
test "raises when no conversion rate available and no fallback rate provided" do
ExchangeRate.expects(:find_rate).returns(nil)
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
assert_raises Money::ConversionError do
Money.new(1000).exchange_to(:jpy)
@ -105,7 +105,7 @@ class MoneyTest < ActiveSupport::TestCase
end
test "converts currency with a fallback rate" do
ExchangeRate.expects(:find_rate).returns(nil).twice
ExchangeRate.expects(:find_or_fetch_rate).returns(nil).twice
assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0)
assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1)

View file

@ -2,7 +2,7 @@ require "test_helper"
require "ostruct"
class Account::ConvertibleTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
include Account::EntriesTestHelper, ProviderTestHelper
setup do
@family = families(:empty)
@ -16,33 +16,28 @@ class Account::ConvertibleTest < ActiveSupport::TestCase
end
test "syncs required exchange rates for an account" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 9500, currency: "EUR")
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR")
# Since we had a valuation 5 days ago, this account starts 6 days ago and needs daily exchange rates looking forward
assert_equal 6.days.ago.to_date, @account.start_date
# Since we had a valuation 1 day ago, this account starts 2 days ago and needs daily exchange rates looking forward
assert_equal 2.days.ago.to_date, @account.start_date
@provider.expects(:fetch_exchange_rates)
.with(
from: "EUR",
to: "USD",
start_date: 6.days.ago.to_date,
end_date: Date.current
).returns(
OpenStruct.new(
success?: true,
ExchangeRate.delete_all
provider_response = provider_success_response(
ExchangeRate::Provideable::FetchRatesData.new(
rates: [
OpenStruct.new(date: 6.days.ago.to_date, rate: 1.1),
OpenStruct.new(date: 5.days.ago.to_date, rate: 1.2),
OpenStruct.new(date: 4.days.ago.to_date, rate: 1.3),
OpenStruct.new(date: 3.days.ago.to_date, rate: 1.4),
OpenStruct.new(date: 2.days.ago.to_date, rate: 1.5),
OpenStruct.new(date: 1.day.ago.to_date, rate: 1.6),
OpenStruct.new(date: Date.current, rate: 1.7)
ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1),
ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 1.day.ago.to_date, rate: 1.2),
ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.3)
]
)
)
assert_difference "ExchangeRate.count", 7 do
@provider.expects(:fetch_exchange_rates)
.with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current)
.returns(provider_response)
assert_difference "ExchangeRate.count", 3 do
@account.sync_required_exchange_rates
end
end

View file

@ -1,63 +1,93 @@
require "test_helper"
class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
include Account::EntriesTestHelper, ProviderTestHelper
setup do
# Prices, highest to lowest priority
@db_price = 210
@provider_price = 220
@trade_price = 200
@holding_price = 250
@provider = mock
Security.stubs(:provider).returns(@provider)
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new)
@test_security = Security.create!(name: "Test Security", ticker: "TEST")
@account = families(:empty).accounts.create!(
name: "Test Brokerage",
balance: 10000,
currency: "USD",
accountable: Investment.new
)
@trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price)
@holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD")
Security::Price.create!(security: @test_security, date: Date.current, price: @db_price)
@security = Security.create!(name: "Test Security", ticker: "TEST", exchange_operating_mic: "TEST")
@trade = create_trade(@security, account: @account, qty: 1, date: 2.days.ago.to_date, price: 210.23).account_trade
end
test "gets price from DB if available" do
cache = Account::Holding::PortfolioCache.new(@account)
db_price = 210
assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price
Security::Price.create!(
security: @security,
date: Date.current,
price: db_price
)
expect_provider_prices([], start_date: @account.start_date)
cache = Account::Holding::PortfolioCache.new(@account)
assert_equal db_price, cache.get_price(@security.id, Date.current).price
end
test "if no price in DB, try fetching from provider" do
Security::Price.destroy_all
Security::Price.expects(:find_prices)
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
.returns([
Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD")
])
Security::Price.delete_all
provider_price = Security::Price.new(
security: @security,
date: Date.current,
price: 220,
currency: "USD"
)
expect_provider_prices([ provider_price ], start_date: @account.start_date)
cache = Account::Holding::PortfolioCache.new(@account)
assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price
assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price
end
test "if no price from db or provider, try getting the price from trades" do
Security::Price.destroy_all # No DB prices
Security::Price.expects(:find_prices)
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
.returns([]) # No provider prices
Security::Price.destroy_all
expect_provider_prices([], start_date: @account.start_date)
cache = Account::Holding::PortfolioCache.new(@account)
assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price
assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price
end
test "if no price from db, provider, or trades, search holdings" do
Security::Price.destroy_all # No DB prices
Security::Price.expects(:find_prices)
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
.returns([]) # No provider prices
Security::Price.delete_all
Account::Entry.delete_all
@account.entries.destroy_all # No prices from trades
holding = Account::Holding.create!(
security: @security,
account: @account,
date: Date.current,
qty: 1,
price: 250,
amount: 250 * 1,
currency: "USD"
)
expect_provider_prices([], start_date: @account.start_date)
cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true)
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
end
assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price
private
def expect_provider_prices(prices, start_date:, end_date: Date.current)
@provider.expects(:fetch_security_prices)
.with(@security, start_date: start_date, end_date: end_date)
.returns(
provider_success_response(
Security::Provideable::PricesData.new(
prices: prices
)
)
)
end
end

View file

@ -2,116 +2,99 @@ require "test_helper"
require "ostruct"
class ExchangeRateTest < ActiveSupport::TestCase
include ProviderTestHelper
setup do
@provider = mock
ExchangeRate.stubs(:provider).returns(@provider)
end
test "exchange rate provider nil if no api key configured" do
ExchangeRate.unstub(:provider)
test "finds rate in DB" do
existing_rate = exchange_rates(:one)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not ExchangeRate.provider
end
end
test "finds single rate in DB" do
@provider.expects(:fetch_exchange_rate).never
rate = exchange_rates(:one)
assert_equal rate, ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date)
end
test "finds single rate from provider and caches to DB" do
expected_rate = 1.21
@provider.expects(:fetch_exchange_rate).once.returns(OpenStruct.new(success?: true, rate: expected_rate))
fetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
refetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
assert_equal expected_rate, fetched_rate.rate
assert_equal expected_rate, refetched_rate.rate
end
test "nil if rate is not found in DB and provider throws an error" do
@provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false))
assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
test "nil if rate is not found in DB and provider is disabled" do
ExchangeRate.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
end
test "finds multiple rates in DB" do
@provider.expects(:fetch_exchange_rate).never
rate1 = exchange_rates(:one) # EUR -> GBP, today
rate2 = exchange_rates(:two) # EUR -> GBP, yesterday
fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date)
assert_equal rate1, fetched_rates[1]
assert_equal rate2, fetched_rates[0]
end
test "finds multiple rates from provider and caches to DB" do
@provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, end_date: Date.current)
.returns(
OpenStruct.new(
rates: [
OpenStruct.new(date: 1.day.ago.to_date, rate: 1.1),
OpenStruct.new(date: Date.current, rate: 1.2)
],
success?: true
assert_equal existing_rate, ExchangeRate.find_or_fetch_rate(
from: existing_rate.from_currency,
to: existing_rate.to_currency,
date: existing_rate.date
)
).once
fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true)
refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date)
assert_equal [ 1.1, 1.2 ], fetched_rates.sort_by(&:date).map(&:rate)
assert_equal [ 1.1, 1.2 ], refetched_rates.sort_by(&:date).map(&:rate)
end
test "finds missing db rates from provider and appends to results" do
@provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date)
.returns(
OpenStruct.new(
rates: [
OpenStruct.new(date: 2.day.ago.to_date, rate: 1.1)
],
success?: true
test "fetches rate from provider without cache" do
ExchangeRate.delete_all
provider_response = provider_success_response(
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new(
from_currency: "USD",
to_currency: "EUR",
date: Date.current,
rate: 1.2
)
)
)
).once
rate1 = exchange_rates(:one) # EUR -> GBP, today
rate2 = exchange_rates(:two) # EUR -> GBP, yesterday
@provider.expects(:fetch_exchange_rate).returns(provider_response)
fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, cache: true)
refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date)
assert_equal [ 1.1, rate2.rate, rate1.rate ], fetched_rates.sort_by(&:date).map(&:rate)
assert_equal [ 1.1, rate2.rate, rate1.rate ], refetched_rates.sort_by(&:date).map(&:rate)
assert_no_difference "ExchangeRate.count" do
assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: false).rate
end
end
test "returns empty array if no rates found in DB or provider" do
ExchangeRate.unstub(:provider)
test "fetches rate from provider with cache" do
ExchangeRate.delete_all
Setting.stubs(:synth_api_key).returns(nil)
provider_response = provider_success_response(
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new(
from_currency: "USD",
to_currency: "EUR",
date: Date.current,
rate: 1.2
)
)
)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date)
@provider.expects(:fetch_exchange_rate).returns(provider_response)
assert_difference "ExchangeRate.count", 1 do
assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true).rate
end
end
test "returns nil on provider error" do
provider_response = provider_error_response(Provider::ProviderError.new("Test error"))
@provider.expects(:fetch_exchange_rate).returns(provider_response)
assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
end
test "upserts rates for currency pair and date range" do
ExchangeRate.delete_all
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9)
provider_response = provider_success_response(
ExchangeRate::Provideable::FetchRatesData.new(
rates: [
ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 1.3),
ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 1.4),
ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.5)
]
)
)
@provider.expects(:fetch_exchange_rates)
.with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current)
.returns(provider_response)
ExchangeRate.sync_provider_rates(from: "USD", to: "EUR", start_date: 2.days.ago.to_date)
assert_equal 1.3, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate
assert_equal 1.4, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date).rate
assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date).rate
end
end

View file

@ -2,55 +2,42 @@ require "test_helper"
require "ostruct"
class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest
include ExchangeRateProviderInterfaceTest, SecurityProviderInterfaceTest
setup do
@subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"])
end
test "fetches paginated securities prices" do
VCR.use_cassette("synth/security_prices") do
response = @synth.fetch_security_prices(
ticker: "AAPL",
mic_code: "XNAS",
start_date: Date.iso8601("2024-01-01"),
end_date: Date.iso8601("2024-08-01")
test "health check" do
VCR.use_cassette("synth/health") do
assert @synth.healthy?
end
end
test "usage info" do
VCR.use_cassette("synth/usage") do
usage = @synth.usage.data
assert usage.used.present?
assert usage.limit.present?
assert usage.utilization.present?
assert usage.plan.present?
end
end
test "enriches transaction" do
VCR.use_cassette("synth/transaction_enrich") do
response = @synth.enrich_transaction(
"UBER EATS",
amount: 25.50,
date: Date.iso8601("2025-03-16"),
city: "San Francisco",
state: "CA",
country: "US"
)
assert 213, response.size
end
end
test "fetches paginated exchange_rate historical data" do
VCR.use_cassette("synth/exchange_rate_historical") do
response = @synth.fetch_exchange_rates(
from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024")
)
assert 213, response.rates.size # 213 days between 01.01.2024 and 31.07.2024
assert_equal [ :date, :rate ], response.rates.first.keys
end
end
test "retries then provides failed response" do
@client = mock
Faraday.stubs(:new).returns(@client)
@client.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
assert_match "Failed to fetch data from Provider::Synth", response.error.message
end
test "retrying, then raising on network error" do
@client = mock
Faraday.stubs(:new).returns(@client)
@client.expects(:get).raises(Faraday::TimeoutError).times(3)
assert_raises Faraday::TimeoutError do
@synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
data = response.data
assert data.name.present?
assert data.category.present?
end
end
end

View file

@ -0,0 +1,61 @@
require "test_helper"
require "ostruct"
class TestProvider < Provider
def fetch_data
provider_response(retries: 3) do
client.get("/test")
end
end
private
def client
@client ||= Faraday.new
end
def retryable_errors
[ Faraday::TimeoutError ]
end
end
class ProviderTest < ActiveSupport::TestCase
setup do
@provider = TestProvider.new
end
test "retries then provides failed response" do
client = mock
Faraday.stubs(:new).returns(client)
client.expects(:get)
.with("/test")
.raises(Faraday::TimeoutError)
.times(3)
response = @provider.fetch_data
assert_not response.success?
assert_match "timeout", response.error.message
end
test "fail, retry, succeed" do
client = mock
Faraday.stubs(:new).returns(client)
sequence = sequence("retry_sequence")
client.expects(:get)
.with("/test")
.raises(Faraday::TimeoutError)
.in_sequence(sequence)
client.expects(:get)
.with("/test")
.returns(Provider::ProviderResponse.new(success?: true, data: "success", error: nil))
.in_sequence(sequence)
response = @provider.fetch_data
assert response.success?
end
end

View file

@ -0,0 +1,27 @@
require "test_helper"
class ProvidersTest < ActiveSupport::TestCase
test "synth configured with ENV" do
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: "123" do
assert_instance_of Provider::Synth, Providers.synth
end
end
test "synth configured with Setting" do
Setting.stubs(:synth_api_key).returns("123")
with_env_overrides SYNTH_API_KEY: nil do
assert_instance_of Provider::Synth, Providers.synth
end
end
test "synth not configured" do
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_nil Providers.synth
end
end
end

View file

@ -2,120 +2,82 @@ require "test_helper"
require "ostruct"
class Security::PriceTest < ActiveSupport::TestCase
include ProviderTestHelper
setup do
@provider = mock
Security.stubs(:provider).returns(@provider)
Security::Price.stubs(:provider).returns(@provider)
end
test "security price provider nil if no api key provided" do
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not Security::Price.provider
end
@security = securities(:aapl)
end
test "finds single security price in DB" do
@provider.expects(:fetch_security_prices).never
security = securities(:aapl)
@provider.expects(:fetch_security_price).never
price = security_prices(:one)
assert_equal price, Security::Price.find_price(security: security, date: price.date)
assert_equal price, @security.find_or_fetch_price(date: price.date)
end
test "caches prices to DB" do
expected_price = 314.34
security = securities(:aapl)
tomorrow = Date.current + 1.day
test "caches prices from provider to DB" do
price_date = 10.days.ago.to_date
@provider.expects(:fetch_security_prices)
.with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: tomorrow, end_date: tomorrow)
.once
.returns(
OpenStruct.new(
success?: true,
prices: [ { date: tomorrow, price: expected_price, currency: "USD" } ]
)
expected_price = Security::Price.new(
security: @security,
date: price_date,
price: 314.34,
currency: "USD"
)
fetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true)
refetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true)
expect_provider_price(security: @security, price: expected_price, date: price_date)
assert_equal expected_price, fetched_rate.price
assert_equal expected_price, refetched_rate.price
assert_difference "Security::Price.count", 1 do
fetched_price = @security.find_or_fetch_price(date: price_date, cache: true)
assert_equal expected_price.price, fetched_price.price
end
end
test "returns nil if no price found in DB or from provider" do
security = securities(:aapl)
Security::Price.delete_all # Clear any existing prices
provider_response = provider_error_response(Provider::ProviderError.new("Test error"))
@provider.expects(:fetch_security_price)
.with(security, date: Date.current)
.returns(provider_response)
assert_not @security.find_or_fetch_price(date: Date.current)
end
test "upserts historical prices from provider" do
Security::Price.delete_all
# Will be overwritten by upsert
Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD")
expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [
Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"),
Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"),
Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD")
])
@security.sync_provider_prices(start_date: 2.days.ago.to_date)
assert_equal 215, @security.prices.find_by(date: Date.current).price
assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price
assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price
end
private
def expect_provider_price(security:, price:, date:)
@provider.expects(:fetch_security_price)
.with(security, date: date)
.returns(provider_success_response(Security::Provideable::PriceData.new(price: price)))
end
def expect_provider_prices(security:, prices:, start_date:, end_date:)
@provider.expects(:fetch_security_prices)
.with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: Date.current, end_date: Date.current)
.once
.returns(OpenStruct.new(success?: false))
assert_not Security::Price.find_price(security: security, date: Date.current)
end
test "returns nil if price not found in DB and provider disabled" do
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
security = Security.new(ticker: "NVDA")
with_env_overrides SYNTH_API_KEY: nil do
assert_not Security::Price.find_price(security: security, date: Date.current)
end
end
test "fetches multiple dates at once" do
@provider.expects(:fetch_security_prices).never
security = securities(:aapl)
price1 = security_prices(:one) # AAPL today
price2 = security_prices(:two) # AAPL yesterday
fetched_prices = Security::Price.find_prices(security: security, start_date: 1.day.ago.to_date, end_date: Date.current).sort_by(&:date)
assert_equal price1, fetched_prices[1]
assert_equal price2, fetched_prices[0]
end
test "caches multiple prices to DB" do
missing_price = 213.21
security = securities(:aapl)
@provider.expects(:fetch_security_prices)
.with(ticker: security.ticker,
mic_code: security.exchange_operating_mic,
start_date: 2.days.ago.to_date,
end_date: 2.days.ago.to_date)
.returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price, currency: "USD" } ]))
.once
price1 = security_prices(:one) # AAPL today
price2 = security_prices(:two) # AAPL yesterday
fetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true)
refetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true)
assert_equal [ missing_price, price2.price, price1.price ], fetched_prices.sort_by(&:date).map(&:price)
assert_equal [ missing_price, price2.price, price1.price ], refetched_prices.sort_by(&:date).map(&:price)
assert Security::Price.exists?(security: security, date: 2.days.ago.to_date, price: missing_price)
end
test "returns empty array if no prices found in DB or from provider" do
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], Security::Price.find_prices(security: Security.new(ticker: "NVDA"), start_date: 10.days.ago.to_date, end_date: Date.current)
end
.with(security, start_date: start_date, end_date: end_date)
.returns(provider_success_response(Security::Provideable::PricesData.new(prices: prices)))
end
end

View file

@ -6,6 +6,8 @@ class TradeImportTest < ActiveSupport::TestCase
setup do
@subject = @import = imports(:trade)
@provider = mock
Security.stubs(:provider).returns(@provider)
end
test "imports trades and accounts" do
@ -14,7 +16,7 @@ class TradeImportTest < ActiveSupport::TestCase
# We should only hit the provider for GOOGL since AAPL already exists
Security.expects(:search_provider).with(
query: "GOOGL",
"GOOGL",
exchange_operating_mic: "XNAS"
).returns([
Security.new(

View file

@ -0,0 +1,17 @@
module ProviderTestHelper
def provider_success_response(data)
Provider::ProviderResponse.new(
success?: true,
data: data,
error: nil
)
end
def provider_error_response(error)
Provider::ProviderResponse.new(
success?: false,
data: nil,
error: error
)
end
end

View file

@ -5,6 +5,9 @@ class ImportsTest < ApplicationSystemTestCase
setup do
sign_in @user = users(:family_admin)
# Trade securities will be imported as "offline" tickers
Security.stubs(:provider).returns(nil)
end
test "transaction import" do
@ -52,8 +55,6 @@ class ImportsTest < ApplicationSystemTestCase
end
test "trade import" do
Security.stubs(:search_provider).returns([])
visit new_import_path
click_on "Import investments"

View file

@ -33,6 +33,7 @@ class SettingsTest < ApplicationSystemTestCase
test "can update self hosting settings" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
Providers.stubs(:synth).returns(nil)
open_settings_from_sidebar
assert_selector "li", text: "Self hosting"
click_link "Self hosting"

View file

@ -10,16 +10,8 @@ class TradesTest < ApplicationSystemTestCase
visit_account_portfolio
Security.stubs(:search_provider).returns([
Security.new(
ticker: "AAPL",
name: "Apple Inc.",
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
exchange_acronym: "NASDAQ",
exchange_mic: "XNAS",
country_code: "US"
)
])
# Disable provider to focus on form testing
Security.stubs(:provider).returns(nil)
end
test "can create buy transaction" do
@ -28,7 +20,6 @@ class TradesTest < ApplicationSystemTestCase
open_new_trade_modal
fill_in "Ticker symbol", with: "AAPL"
select_combobox_option("Apple")
fill_in "Date", with: Date.current
fill_in "Quantity", with: shares_qty
fill_in "account_entry[price]", with: 214.23
@ -50,7 +41,6 @@ class TradesTest < ApplicationSystemTestCase
select "Sell", from: "Type"
fill_in "Ticker symbol", with: aapl.ticker
select_combobox_option(aapl.security.name)
fill_in "Date", with: Date.current
fill_in "Quantity", with: aapl.qty
fill_in "account_entry[price]", with: 215.33
@ -81,10 +71,4 @@ class TradesTest < ApplicationSystemTestCase
def visit_account_portfolio
visit account_path(@account, tab: "holdings")
end
def select_combobox_option(text)
within "#account_entry_ticker-hw-listbox" do
find("li", text: text).click
end
end
end

View file

@ -2,15 +2,19 @@
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN
uri: https://api.synthfinance.com/rates/historical?date=2024-01-01&from=USD&to=GBP
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday v2.10.0
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -21,29 +25,25 @@ http_interactions:
message: OK
headers:
Date:
- Thu, 01 Aug 2024 17:20:28 GMT
- Sat, 15 Mar 2025 22:18:46 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"
- W/"b0b21c870fe53492404cc5ac258fa465"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 44367fcb-e5b4-457d
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:
@ -53,17 +53,29 @@ http_interactions:
X-Render-Origin-Server:
- Render
X-Request-Id:
- 61992b01-969b-4af5-8119-9b17e385da07
- 8ce9dc85-afbd-437c-b18d-ec788b712334
X-Runtime:
- '0.369358'
- '0.031963'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=SwRPS1vBsrKtk%2Ftb7Ix8j%2FCWYw9tZgbJxR1FCmotWn%2FIZAE3Ri%2FUwHtvkOSqBq6HN5pLVetfem5hp%2BkqWmD5GRCVho0mp3VgRr3J1tBMwrVK2p50tfpmb3X22Jj%2BOfapq1C22PnN"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 920f6378fe582237-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=26670&min_rtt=26569&rtt_var=10167&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=922&delivery_rate=105759&cwnd=181&unsent_bytes=0&cid=f0a872e0b2909c59&ts=188&x=0"
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
string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249830,"date":"2024-01-01"}}'
recorded_at: Sat, 15 Mar 2025 22:18:46 GMT
recorded_with: VCR 6.3.1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,82 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/user
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sat, 15 Mar 2025 22:18:47 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"4ec3e0a20895d90b1e1241ca67f10ca3"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 0cab64c9-e312-4bec
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 1958563c-7c18-4201-a03c-a4b343dc68ab
X-Runtime:
- '0.014938'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=P3OWn4c8LFFWI0Dwr2CSYwHLaNhf9iD9TfAhqdx5PtLoWZ0pSImebfUsh00ZbOmh4r2cRJEQOmvy67wAwl6p0W%2Fx9017EkCnCaXibBBCKqJTBOdGnsSuV%2B45LrHsQmg%2BGeBwrw4b"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 920f637aa8cf1152-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=25627&min_rtt=25594&rtt_var=9664&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=878&delivery_rate=111991&cwnd=248&unsent_bytes=0&cid=c8e4c4e269114d14&ts=263&x=0"
body:
encoding: ASCII-8BIT
string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test
User","plan":"Business","api_calls_remaining":249830,"api_limit":250000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}'
recorded_at: Sat, 15 Mar 2025 22:18:47 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,105 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/tickers/AAPL?operating_mic=XNAS
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sun, 16 Mar 2025 12:04:12 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"a9deeb6437d359f080be449b9b2c547b"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 1e77ae49-050a-45fc
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 222dacf1-37f3-4eb8-91d5-edf13d732d46
X-Runtime:
- '0.059222'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BLW%2Fd%2BbcNg4%2FleO6ECyB4RJBMbm6vWG3%2FX4oKQXfn1ROSPVrISc3ZFVlXfITGW4XYJSPyUDF%2FXrrRF6p3Wzow07QamOrsux7sxBMvtWmcubgpCMFI4zgnhESklW6KcmAefwrgj9i"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 92141c97bfd9124c-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=27459&min_rtt=26850&rtt_var=11288&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=905&delivery_rate=91272&cwnd=104&unsent_bytes=0&cid=ccd6aa7e48e4b0eb&ts=287&x=0"
body:
encoding: ASCII-8BIT
string: '{"data":{"ticker":"AAPL","name":"Apple Inc.","links":{"homepage_url":"https://www.apple.com"},"logo_url":"https://logo.synthfinance.com/ticker/AAPL","description":"Apple
Inc. designs, manufactures, and markets smartphones, personal computers, tablets,
wearables, and accessories worldwide. The company offers iPhone, a line of
smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose
tablets; and wearables, home, and accessories comprising AirPods, Apple TV,
Apple Watch, Beats products, and HomePod. It also provides AppleCare support
and cloud services; and operates various platforms, including the App Store
that allow customers to discover and download applications and digital content,
such as books, music, video, games, and podcasts. In addition, the company
offers various services, such as Apple Arcade, a game subscription service;
Apple Fitness+, a personalized fitness service; Apple Music, which offers
users a curated listening experience with on-demand radio stations; Apple
News+, a subscription news and magazine service; Apple TV+, which offers exclusive
original content; Apple Card, a co-branded credit card; and Apple Pay, a cashless
payment service, as well as licenses its intellectual property. The company
serves consumers, and small and mid-sized businesses; and the education, enterprise,
and government markets. It distributes third-party applications for its products
through the App Store. The company also sells its products through its retail
and online stores, and direct sales force; and third-party cellular network
carriers, wholesalers, retailers, and resellers. Apple Inc. was founded in
1976 and is headquartered in Cupertino, California.","kind":"common stock","cik":"0000320193","currency":"USD","address":{"country":"USA","address_line1":"One
Apple Park Way","city":"Cupertino","state":"CA","postal_code":"95014"},"exchange":{"name":"Nasdaq/Ngs
(Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"},"ceo":"Mr. Timothy
D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":213.95,"low_today":209.58,"open_today":211.25,"close_today":213.49,"volume_today":60060200.0,"fifty_two_week_high":260.1,"fifty_two_week_low":164.08,"average_volume":62848099.37313433,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249808}}'
recorded_at: Sun, 16 Mar 2025 12:04:12 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,83 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=1&start_date=2024-08-01
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sun, 16 Mar 2025 12:08:00 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"cdf04c2cd77e230c03117dd13d0921f9"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- e74b3425-0b7c-447d
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- b906c5e1-18cc-44cc-9085-313ff066a6ce
X-Runtime:
- '0.544708'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dZNe6qCGGI2XGXgByLr69%2FYrDQdy2FLtnXafxJnlsvyVjrRFiCvmbbIzgF5CDgtj9HZ8RC5Rh9jbuEI6hPokpa3Al4FEIAZB5AbfZ9toP%2Bc5muG%2FuBgHR%2FnIZpsWG%2BQKmBPu9MBa"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 921422292d0feacc-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=30826&min_rtt=26727&rtt_var=12950&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=108354&cwnd=219&unsent_bytes=0&cid=43c717161effdc57&ts=695&x=0"
body:
encoding: ASCII-8BIT
string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global
Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249807}}'
recorded_at: Sun, 16 Mar 2025 12:08:00 GMT
recorded_with: VCR 6.3.1

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,104 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/tickers/search?country_code=US&dataset=limited&limit=25&name=AAPL
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sun, 16 Mar 2025 12:01:58 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"3e444869eacbaf17006766a691cc8fdc"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 2effb56b-f67f-402d
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 33470619-5119-4923-b4e0-e9a0eeb532a1
X-Runtime:
- '0.453770'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ayZOlXkCwLgUl%2FrB2%2BlqtqR5HCllubf4HLDipEt3klWKyHS4nilHi9XZ1fiEQWx7xwiRMJZ5EW0Xzm7ISoHWTtEbkgMQHWYQwSTeg30ahFFHK1pkOOnET1fuW1UxiZwlJtq1XZGB"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 921419514e0a6399-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=25809&min_rtt=25801&rtt_var=9692&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=939&delivery_rate=111952&cwnd=121&unsent_bytes=0&cid=2beb787f15cd8ab9&ts=610&x=0"
body:
encoding: ASCII-8BIT
string: '{"data":[{"symbol":"AAPL","name":"Apple Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs
(Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"APLY","isin":"US88634T8577","name":"YieldMax
AAPL Option Income ETF","logo_url":"https://logo.synthfinance.com/ticker/APLY","currency":"USD","exchange":{"name":"Nyse
Arca","mic_code":"ARCX","operating_mic_code":"XNYS","acronym":"NYSE","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPD","name":"Direxion
Daily AAPL Bear 1X ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPD","currency":"USD","exchange":{"name":"Nasdaq/Nms
(Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion
Daily AAPL Bull 2X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","currency":"USD","exchange":{"name":"Nasdaq/Nms
(Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPB","isin":"XXXXXXXR8842","name":"GraniteShares
2x Long AAPL Daily ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPB","currency":"USD","exchange":{"name":"Nasdaq/Ngs
(Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPD","isin":"US25461A3041","name":"Direxion
Daily AAPL Bear 1X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPD","currency":"USD","exchange":{"name":"Nasdaq/Ngs
(Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion
Daily AAPL Bull 1.5X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","currency":"USD","exchange":{"name":"Nasdaq/Ngs
(Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United
States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPJ","isin":"US00037T1034","name":"AAP,
Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPJ","currency":"USD","exchange":{"name":"Otc
Pink Marketplace","mic_code":"PINX","operating_mic_code":"OTCM","acronym":"","country":"United
States","country_code":"US","timezone":"America/New_York"}}]}'
recorded_at: Sun, 16 Mar 2025 12:01:58 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,82 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/enrich?amount=25.5&city=San%20Francisco&country=US&date=2025-03-16&description=UBER%20EATS&state=CA
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sun, 16 Mar 2025 12:09:33 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"00411c83cfeaade519bcc3e57d9e461e"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 56a8791d-85ed-4342
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- 1b35b9c1-0092-40b1-8b70-2bce7c5796af
X-Runtime:
- '0.884634'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=qUtB0aWbK%2Fh5W7cV%2FugsUGbWKtJzsf%2FXd5i8cm8KlepEtLyuVPH7XX0fqwzHp43OCWQkGr9r8hRBBSEcx9LWW5vS7%2B1kXCJaKPaTRn%2BWtsEymHg78OHqDcMahwSuy%2FkpSGLWo0or"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 921424681aa4acab-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=26975&min_rtt=26633&rtt_var=10231&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=969&delivery_rate=108737&cwnd=210&unsent_bytes=0&cid=318ff675628918e1&ts=1035&x=0"
body:
encoding: ASCII-8BIT
string: '{"merchant":"Uber Eats","merchant_id":"mer_aea41e7f29ce47b5873f3caf49d5972d","category":"Dining
Out","website":"ubereats.com","icon":"https://logo.synthfinance.com/ubereats.com","meta":{"credits_used":1,"credits_remaining":249806}}'
recorded_at: Sun, 16 Mar 2025 12:09:33 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,82 @@
---
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/user
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- Bearer <SYNTH_API_KEY>
X-Source:
- maybe_app
X-Source-Type:
- managed
User-Agent:
- Faraday v2.12.2
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Date:
- Sat, 15 Mar 2025 22:18:47 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"4ec3e0a20895d90b1e1241ca67f10ca3"
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 54c8ecf9-6858-4db6
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Render-Origin-Server:
- Render
X-Request-Id:
- a4112cfb-0eac-4e3e-a880-7536d90dcba0
X-Runtime:
- '0.007036'
X-Xss-Protection:
- '0'
Cf-Cache-Status:
- DYNAMIC
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Rt0BTtrgXzYjWOQFgb%2Bg6N4xKvXtPI66Q251bq9nWtqUhGHo17GmVVAPkutwN7Gisw1RmvYfxYUiMCCxlc4%2BjuHxbU1%2BXr9KHy%2F5pUpLhgLNNrtkqqKOCW4GduODnDbw2I38Rocu"}],"group":"cf-nel","max_age":604800}'
Nel:
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
Speculation-Rules:
- '"/cdn-cgi/speculation"'
Server:
- cloudflare
Cf-Ray:
- 920f637d1fe8eb68-ORD
Alt-Svc:
- h3=":443"; ma=86400
Server-Timing:
- cfL4;desc="?proto=TCP&rtt=28779&min_rtt=27036&rtt_var=11384&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=878&delivery_rate=107116&cwnd=203&unsent_bytes=0&cid=52bc39ad09dd9eff&ts=145&x=0"
body:
encoding: ASCII-8BIT
string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test
User","plan":"Business","api_calls_remaining":1200,"api_limit":5000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}'
recorded_at: Sat, 15 Mar 2025 22:18:47 GMT
recorded_with: VCR 6.3.1