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:
parent
dd75cadebc
commit
f65b93a352
95 changed files with 2014 additions and 1638 deletions
|
@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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
|
|
@ -1,3 +1,5 @@
|
|||
SELF_HOSTED=false
|
||||
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -11,7 +11,6 @@
|
|||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.test
|
||||
!.env*.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable, Provided
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
module Account::Entry::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
def fetch_enrichment_info
|
||||
return nil unless synth_client.present?
|
||||
|
||||
synth_client.enrich_transaction(name).info
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
15
app/models/account/transaction/provided.rb
Normal file
15
app/models/account/transaction/provided.rb
Normal 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
|
|
@ -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
|
|
@ -1,37 +0,0 @@
|
|||
module Synthable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def synth_usage
|
||||
synth_client&.usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
synth_usage&.usage&.utilization.to_i >= 100
|
||||
end
|
||||
|
||||
def synth_healthy?
|
||||
synth_client&.healthy?
|
||||
end
|
||||
|
||||
def synth_client
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
Provider::Synth.new(api_key)
|
||||
end
|
||||
end
|
||||
|
||||
def synth_client
|
||||
self.class.synth_client
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
self.class.synth_overage?
|
||||
end
|
||||
end
|
|
@ -2,27 +2,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
|
||||
|
|
15
app/models/exchange_rate/provideable.rb
Normal file
15
app/models/exchange_rate/provideable.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# Defines the interface an exchange rate provider must implement
|
||||
module ExchangeRate::Provideable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
FetchRateData = Data.define(:rate)
|
||||
FetchRatesData = Data.define(:rates)
|
||||
|
||||
def fetch_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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
11
app/models/financial_assistant.rb
Normal file
11
app/models/financial_assistant.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class FinancialAssistant
|
||||
include Provided
|
||||
|
||||
def initialize(chat)
|
||||
@chat = chat
|
||||
end
|
||||
|
||||
def query(prompt, model_key: "gpt-4o")
|
||||
llm_provider = self.class.llm_provider_for(model_key)
|
||||
end
|
||||
end
|
13
app/models/financial_assistant/provided.rb
Normal file
13
app/models/financial_assistant/provided.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
module FinancialAssistant::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Placeholder for AI chat PR
|
||||
def llm_provider_for(model_key)
|
||||
case model_key
|
||||
when "gpt-4o"
|
||||
Providers.openai
|
||||
else
|
||||
raise "Unknown LLM model key: #{model_key}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,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
|
|
@ -1,9 +0,0 @@
|
|||
class Issue::ExchangeRateProviderMissing < Issue
|
||||
def default_severity
|
||||
:error
|
||||
end
|
||||
|
||||
def stale?
|
||||
ExchangeRate.provider_healthy?
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -1,6 +1,4 @@
|
|||
class PlaidAccount < ApplicationRecord
|
||||
include Plaidable
|
||||
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class PlaidItem < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
include Provided, Syncable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
module Plaidable
|
||||
module PlaidItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def plaid_us_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid
|
||||
Providers.plaid_us
|
||||
end
|
||||
|
||||
def plaid_eu_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu
|
||||
Providers.plaid_eu
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
35
app/models/provider.rb
Normal file
35
app/models/provider.rb
Normal 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
|
|
@ -1,18 +0,0 @@
|
|||
|
||||
class Provider::Base
|
||||
ProviderError = Class.new(StandardError)
|
||||
|
||||
TRANSIENT_NETWORK_ERRORS = [
|
||||
Faraday::TimeoutError,
|
||||
Faraday::ConnectionFailed,
|
||||
Faraday::SSLError,
|
||||
Faraday::ClientError,
|
||||
Faraday::ServerError
|
||||
]
|
||||
|
||||
class << self
|
||||
def known_transient_errors
|
||||
TRANSIENT_NETWORK_ERRORS + [ ProviderError ]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,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
35
app/models/providers.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
module Security::Price::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
synth_client
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_price_from_provider(security:, date:, cache: false)
|
||||
return nil unless provider.present?
|
||||
return nil unless security.has_prices?
|
||||
|
||||
response = provider.fetch_security_prices \
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_operating_mic,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
|
||||
if response.success? && response.prices.size > 0
|
||||
price = Security::Price.new \
|
||||
security: security,
|
||||
date: response.prices.first[:date],
|
||||
price: response.prices.first[:price],
|
||||
currency: response.prices.first[:currency]
|
||||
|
||||
price.save! if cache
|
||||
price
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false)
|
||||
return [] unless provider.present?
|
||||
return [] unless security
|
||||
return [] unless security.has_prices?
|
||||
|
||||
response = provider.fetch_security_prices \
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_operating_mic,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
||||
if response.success?
|
||||
response.prices.map do |price|
|
||||
new_price = Security::Price.find_or_initialize_by(
|
||||
security: security,
|
||||
date: price[:date]
|
||||
) do |p|
|
||||
p.price = price[:price]
|
||||
p.currency = price[:currency]
|
||||
end
|
||||
|
||||
new_price.save! if cache && new_price.new_record?
|
||||
new_price
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
app/models/security/provideable.rb
Normal file
31
app/models/security/provideable.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
module Security::Provideable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
Search = Data.define(:securities)
|
||||
PriceData = Data.define(:price)
|
||||
PricesData = Data.define(:prices)
|
||||
SecurityInfo = Data.define(
|
||||
:ticker,
|
||||
:name,
|
||||
:links,
|
||||
:logo_url,
|
||||
:description,
|
||||
:kind,
|
||||
)
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #search_securities"
|
||||
end
|
||||
|
||||
def fetch_security_info(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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 %>
|
|
@ -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 %>
|
|
@ -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 %>
|
|
@ -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 %>
|
|
@ -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 %>
|
|
@ -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 %>
|
|
@ -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>
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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
|
|
@ -2,7 +2,6 @@
|
|||
en:
|
||||
accounts:
|
||||
account:
|
||||
has_issues: Issue detected.
|
||||
troubleshoot: Troubleshoot
|
||||
chart:
|
||||
no_change: no change
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_column :security_prices, :ticker
|
||||
end
|
||||
end
|
11
db/migrate/20250316103753_remove_issues.rb
Normal file
11
db/migrate/20250316103753_remove_issues.rb
Normal 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
|
31
db/migrate/20250316122019_security_price_unique_index.rb
Normal file
31
db/migrate/20250316122019_security_price_unique_index.rb
Normal 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
27
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_03_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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
5
test/fixtures/issues.yml
vendored
5
test/fixtures/issues.yml
vendored
|
@ -1,5 +0,0 @@
|
|||
one:
|
||||
issuable: depository
|
||||
issuable_type: Account
|
||||
type: Issue::Unknown
|
||||
last_observed_at: 2024-08-15 08:54:04
|
|
@ -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
|
||||
|
|
|
@ -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
|
62
test/interfaces/security_provider_interface_test.rb
Normal file
62
test/interfaces/security_provider_interface_test.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
61
test/models/provider_test.rb
Normal file
61
test/models/provider_test.rb
Normal 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
|
27
test/models/providers_test.rb
Normal file
27
test/models/providers_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
17
test/support/provider_test_helper.rb
Normal file
17
test/support/provider_test_helper.rb
Normal 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
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
81
test/vcr_cassettes/synth/exchange_rates.yml
Normal file
81
test/vcr_cassettes/synth/exchange_rates.yml
Normal file
File diff suppressed because one or more lines are too long
82
test/vcr_cassettes/synth/health.yml
Normal file
82
test/vcr_cassettes/synth/health.yml
Normal 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
|
105
test/vcr_cassettes/synth/security_info.yml
Normal file
105
test/vcr_cassettes/synth/security_info.yml
Normal 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
|
83
test/vcr_cassettes/synth/security_price.yml
Normal file
83
test/vcr_cassettes/synth/security_price.yml
Normal 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
104
test/vcr_cassettes/synth/security_search.yml
Normal file
104
test/vcr_cassettes/synth/security_search.yml
Normal 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
|
82
test/vcr_cassettes/synth/transaction_enrich.yml
Normal file
82
test/vcr_cassettes/synth/transaction_enrich.yml
Normal 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
|
82
test/vcr_cassettes/synth/usage.yml
Normal file
82
test/vcr_cassettes/synth/usage.yml
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue