mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into zachgoll/plaid-sync-domain-improvements
This commit is contained in:
commit
0a1d4fca3d
15 changed files with 87 additions and 50 deletions
|
@ -13,6 +13,7 @@ module AutoSync
|
||||||
def family_needs_auto_sync?
|
def family_needs_auto_sync?
|
||||||
return false unless Current.family&.accounts&.active&.any?
|
return false unless Current.family&.accounts&.active&.any?
|
||||||
return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current
|
return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current
|
||||||
|
return false unless Current.family.auto_sync_on_login
|
||||||
|
|
||||||
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}"
|
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}"
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ class ImportsController < ApplicationController
|
||||||
@import.publish_later
|
@import.publish_later
|
||||||
|
|
||||||
redirect_to import_path(@import), notice: "Your import has started in the background."
|
redirect_to import_path(@import), notice: "Your import has started in the background."
|
||||||
|
rescue Import::MaxRowCountExceededError
|
||||||
|
redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}."
|
||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
class FetchSecurityInfoJob < ApplicationJob
|
|
||||||
queue_as :low_priority
|
|
||||||
|
|
||||||
def perform(security_id)
|
|
||||||
return unless Security.provider.present?
|
|
||||||
|
|
||||||
security = Security.find(security_id)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
ticker: security.ticker
|
|
||||||
}
|
|
||||||
params[:mic_code] = security.exchange_mic if security.exchange_mic.present?
|
|
||||||
params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
|
||||||
|
|
||||||
security_info_response = Security.provider.fetch_security_info(**params)
|
|
||||||
|
|
||||||
security.update(
|
|
||||||
name: security_info_response.info.dig("name")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -54,4 +54,8 @@ class AccountImport < Import
|
||||||
|
|
||||||
CSV.parse(template, headers: true)
|
CSV.parse(template, headers: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def max_row_count
|
||||||
|
50
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,17 +11,18 @@ module Syncable
|
||||||
|
|
||||||
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||||
Sync.transaction do
|
Sync.transaction do
|
||||||
# Expire any previous in-flight syncs for this record that exceeded the
|
# Since we're scheduling a new sync, mark old syncs for this syncable as stale
|
||||||
# global staleness window.
|
self.syncs.incomplete.find_each(&:mark_stale!)
|
||||||
syncs.stale_candidates.find_each(&:mark_stale!)
|
|
||||||
|
|
||||||
new_sync = syncs.create!(
|
new_sync = self.syncs.create!(
|
||||||
parent: parent_sync,
|
parent: parent_sync,
|
||||||
window_start_date: window_start_date,
|
window_start_date: window_start_date,
|
||||||
window_end_date: window_end_date
|
window_end_date: window_end_date
|
||||||
)
|
)
|
||||||
|
|
||||||
SyncJob.perform_later(new_sync)
|
SyncJob.perform_later(new_sync)
|
||||||
|
|
||||||
|
new_sync
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Import < ApplicationRecord
|
class Import < ApplicationRecord
|
||||||
|
MaxRowCountExceededError = Class.new(StandardError)
|
||||||
|
|
||||||
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
|
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
|
||||||
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
|
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
|
||||||
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
|
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
|
||||||
|
@ -52,6 +54,7 @@ class Import < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def publish_later
|
def publish_later
|
||||||
|
raise MaxRowCountExceededError if row_count_exceeded?
|
||||||
raise "Import is not publishable" unless publishable?
|
raise "Import is not publishable" unless publishable?
|
||||||
|
|
||||||
update! status: :importing
|
update! status: :importing
|
||||||
|
@ -60,6 +63,8 @@ class Import < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def publish
|
def publish
|
||||||
|
raise MaxRowCountExceededError if row_count_exceeded?
|
||||||
|
|
||||||
import!
|
import!
|
||||||
|
|
||||||
family.sync_later
|
family.sync_later
|
||||||
|
@ -220,7 +225,15 @@ class Import < ApplicationRecord
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def max_row_count
|
||||||
|
10000
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def row_count_exceeded?
|
||||||
|
rows.count > max_row_count
|
||||||
|
end
|
||||||
|
|
||||||
def import!
|
def import!
|
||||||
# no-op, subclasses can implement for customization of algorithm
|
# no-op, subclasses can implement for customization of algorithm
|
||||||
end
|
end
|
||||||
|
|
|
@ -71,9 +71,11 @@ class Provider::Synth < Provider
|
||||||
rate = rate.dig("rates", to)
|
rate = rate.dig("rates", to)
|
||||||
|
|
||||||
if date.nil? || rate.nil?
|
if date.nil? || rate.nil?
|
||||||
message = "#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}"
|
Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}")
|
||||||
Rails.logger.warn(message)
|
Sentry.capture_exception(InvalidExchangeRateError.new("#{self.class.name} returned invalid rate data"), level: :warning) do |scope|
|
||||||
Sentry.capture_exception(InvalidExchangeRateError.new(message), level: :warning)
|
scope.set_context("rate", { from: from, to: to, date: date })
|
||||||
|
end
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -162,9 +164,11 @@ class Provider::Synth < Provider
|
||||||
price = price.dig("close") || price.dig("open")
|
price = price.dig("close") || price.dig("open")
|
||||||
|
|
||||||
if date.nil? || price.nil?
|
if date.nil? || price.nil?
|
||||||
message = "#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}"
|
Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}")
|
||||||
Rails.logger.warn(message)
|
Sentry.capture_exception(InvalidSecurityPriceError.new("#{self.class.name} returned invalid security price data"), level: :warning) do |scope|
|
||||||
Sentry.capture_exception(InvalidSecurityPriceError.new(message), level: :warning)
|
scope.set_context("security", { symbol: symbol, date: date })
|
||||||
|
end
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,16 @@ class Security::Price::Importer
|
||||||
prev_price_value = start_price_value
|
prev_price_value = start_price_value
|
||||||
|
|
||||||
unless prev_price_value.present?
|
unless prev_price_value.present?
|
||||||
error = MissingStartPriceError.new("Could not find a start price for #{security.ticker} on or before #{start_date}")
|
Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{start_date}")
|
||||||
Rails.logger.error(error.message)
|
|
||||||
Sentry.capture_exception(error)
|
Sentry.capture_exception(MissingStartPriceError.new("Could not determine start price for ticker")) do |scope|
|
||||||
|
scope.set_tags(security_id: security.id)
|
||||||
|
scope.set_context("security", {
|
||||||
|
id: security.id,
|
||||||
|
start_date: start_date
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -75,9 +82,12 @@ class Security::Price::Importer
|
||||||
if response.success?
|
if response.success?
|
||||||
response.data.index_by(&:date)
|
response.data.index_by(&:date)
|
||||||
else
|
else
|
||||||
msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}"
|
Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}")
|
||||||
Rails.logger.warn(msg)
|
Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope|
|
||||||
Sentry.capture_exception(MissingSecurityPriceError.new(msg), level: :warning)
|
scope.set_tags(security_id: security.id)
|
||||||
|
scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date })
|
||||||
|
end
|
||||||
|
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
module Security::Provided
|
module Security::Provided
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
SecurityInfoMissingError = Class.new(StandardError)
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def provider
|
def provider
|
||||||
registry = Provider::Registry.for_concept(:securities)
|
registry = Provider::Registry.for_concept(:securities)
|
||||||
|
@ -70,9 +72,11 @@ module Security::Provided
|
||||||
logo_url: response.data.logo_url,
|
logo_url: response.data.logo_url,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
err = StandardError.new("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")
|
Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")
|
||||||
Rails.logger.warn(err.message)
|
Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope|
|
||||||
Sentry.capture_exception(err, level: :warning)
|
scope.set_tags(security_id: self.id)
|
||||||
|
scope.set_context("security", { id: self.id, provider_error: response.error.message })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class Sync < ApplicationRecord
|
class Sync < ApplicationRecord
|
||||||
# We run a cron that marks any syncs that have been running > 2 hours as "stale"
|
# We run a cron that marks any syncs that have not been resolved in 24 hours as "stale"
|
||||||
# Syncs often become stale when new code is deployed and the worker restarts
|
# Syncs often become stale when new code is deployed and the worker restarts
|
||||||
STALE_AFTER = 2.hours
|
STALE_AFTER = 24.hours
|
||||||
|
|
||||||
# The max time that a sync will show in the UI (after 5 minutes)
|
# The max time that a sync will show in the UI (after 5 minutes)
|
||||||
VISIBLE_FOR = 5.minutes
|
VISIBLE_FOR = 5.minutes
|
||||||
|
@ -19,9 +19,6 @@ class Sync < ApplicationRecord
|
||||||
scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
|
scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
|
||||||
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
|
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
|
||||||
|
|
||||||
# In-flight records that have exceeded the allowed runtime
|
|
||||||
scope :stale_candidates, -> { incomplete.where("syncs.created_at < ?", STALE_AFTER.ago) }
|
|
||||||
|
|
||||||
validate :window_valid
|
validate :window_valid
|
||||||
|
|
||||||
# Sync state machine
|
# Sync state machine
|
||||||
|
@ -54,7 +51,7 @@ class Sync < ApplicationRecord
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def clean
|
def clean
|
||||||
stale_candidates.find_each(&:mark_stale!)
|
incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddAutoSyncPreferenceToFamily < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :families, :auto_sync_on_login, :boolean, default: true, null: false
|
||||||
|
end
|
||||||
|
end
|
1
db/schema.rb
generated
1
db/schema.rb
generated
|
@ -227,6 +227,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_133020) do
|
||||||
t.string "timezone"
|
t.string "timezone"
|
||||||
t.boolean "data_enrichment_enabled", default: false
|
t.boolean "data_enrichment_enabled", default: false
|
||||||
t.boolean "early_access", default: false
|
t.boolean "early_access", default: false
|
||||||
|
t.boolean "auto_sync_on_login", default: true, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
|
|
@ -38,4 +38,12 @@ class AutoSyncTest < ActionDispatch::IntegrationTest
|
||||||
get root_path
|
get root_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "does not auto-sync if preference is disabled" do
|
||||||
|
@family.update!(auto_sync_on_login: false)
|
||||||
|
|
||||||
|
assert_no_difference "Sync.count" do
|
||||||
|
get root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,4 +17,12 @@ module SyncableInterfaceTest
|
||||||
@syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once
|
@syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once
|
||||||
@syncable.perform_sync(mock_sync)
|
@syncable.perform_sync(mock_sync)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "any prior syncs for the same syncable entity are marked stale when new sync is requested" do
|
||||||
|
stale_sync = @syncable.sync_later
|
||||||
|
new_sync = @syncable.sync_later
|
||||||
|
|
||||||
|
assert_equal "stale", stale_sync.reload.status
|
||||||
|
assert_equal "pending", new_sync.reload.status
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -172,15 +172,15 @@ class SyncTest < ActiveSupport::TestCase
|
||||||
stale_pending = Sync.create!(
|
stale_pending = Sync.create!(
|
||||||
syncable: accounts(:depository),
|
syncable: accounts(:depository),
|
||||||
status: :pending,
|
status: :pending,
|
||||||
created_at: 3.hours.ago
|
created_at: 25.hours.ago
|
||||||
)
|
)
|
||||||
|
|
||||||
stale_syncing = Sync.create!(
|
stale_syncing = Sync.create!(
|
||||||
syncable: accounts(:depository),
|
syncable: accounts(:depository),
|
||||||
status: :syncing,
|
status: :syncing,
|
||||||
created_at: 3.hours.ago,
|
created_at: 25.hours.ago,
|
||||||
pending_at: 3.hours.ago,
|
pending_at: 24.hours.ago,
|
||||||
syncing_at: 2.hours.ago
|
syncing_at: 23.hours.ago
|
||||||
)
|
)
|
||||||
|
|
||||||
Sync.clean
|
Sync.clean
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue