diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 02e61350e..22936cfb4 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -25,6 +25,7 @@ class Settings::HostingsController < ApplicationController if @show_twelve_data_settings twelve_data_provider = Provider::Registry.get_provider(:twelve_data) @twelve_data_usage = twelve_data_provider&.usage + @plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData") end if @show_yahoo_finance_settings diff --git a/app/models/family.rb b/app/models/family.rb index a69e9629b..af9f0aa98 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -141,6 +141,27 @@ class Family < ApplicationRecord (requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?) end + # Returns securities with plan restrictions for a specific provider + # @param provider [String] The provider name (e.g., "TwelveData") + # @return [Array] Array of hashes with ticker, name, required_plan, provider + def securities_with_plan_restrictions(provider:) + security_ids = trades.joins(:security).pluck("securities.id").uniq + return [] if security_ids.empty? + + restrictions = Security.plan_restrictions_for(security_ids, provider: provider) + return [] if restrictions.empty? + + Security.where(id: restrictions.keys).map do |security| + restriction = restrictions[security.id] + { + ticker: security.ticker, + name: security.name, + required_plan: restriction[:required_plan], + provider: restriction[:provider] + } + end + end + def oldest_entry_date entries.order(:date).first&.date || Date.current end diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 8f9d81a42..fef92e1bc 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -6,6 +6,22 @@ class Provider::TwelveData < Provider InvalidExchangeRateError = Class.new(Error) InvalidSecurityPriceError = Class.new(Error) + # Pattern to detect plan upgrade errors in API responses + PLAN_UPGRADE_PATTERN = /available starting with (\w+)/i + + # Returns true if the error message indicates a plan upgrade is required + def self.plan_upgrade_required?(error_message) + return false if error_message.blank? + PLAN_UPGRADE_PATTERN.match?(error_message) + end + + # Extracts the required plan name from an error message, or nil if not found + def self.extract_required_plan(error_message) + return nil if error_message.blank? + match = error_message.match(PLAN_UPGRADE_PATTERN) + match ? match[1] : nil + end + def initialize(api_key) @api_key = api_key end diff --git a/app/models/security.rb b/app/models/security.rb index 35c2a3876..fb26d4d3e 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,5 +1,5 @@ class Security < ApplicationRecord - include Provided + include Provided, PlanRestrictionTracker # ISO 10383 MIC codes mapped to user-friendly exchange names # Source: https://www.iso20022.org/market-identifier-codes diff --git a/app/models/security/plan_restriction_tracker.rb b/app/models/security/plan_restriction_tracker.rb new file mode 100644 index 000000000..1783f8e5d --- /dev/null +++ b/app/models/security/plan_restriction_tracker.rb @@ -0,0 +1,82 @@ +# Tracks securities that require a higher plan to fetch prices from data providers. +# Uses Rails cache to store restriction info, keyed by provider and security ID. +# This allows the settings page to warn users about tickers that need a paid plan. +# +# Note: Currently API keys are configured at the instance level (not per-family), +# so restrictions are shared across all families using the same provider. +module Security::PlanRestrictionTracker + extend ActiveSupport::Concern + + CACHE_KEY_PREFIX = "security_plan_restriction" + CACHE_EXPIRY = 7.days + + # Map provider names to their classes for plan detection + PROVIDER_CLASSES = { + "TwelveData" => Provider::TwelveData + }.freeze + + class_methods do + # Records that a security requires a higher plan to fetch data + # @param security_id [Integer] The security ID + # @param error_message [String] The error message from the provider + # @param provider [String] The provider name (e.g., "TwelveData") + def record_plan_restriction(security_id:, error_message:, provider:) + provider_class = PROVIDER_CLASSES[provider] + return unless provider_class&.respond_to?(:extract_required_plan) + + required_plan = provider_class.extract_required_plan(error_message) + return unless required_plan + + cache_key = plan_restriction_cache_key(provider, security_id) + Rails.cache.write(cache_key, { + required_plan: required_plan, + provider: provider, + recorded_at: Time.current.iso8601 + }, expires_in: CACHE_EXPIRY) + end + + # Clears the plan restriction for a security (e.g., if user upgrades their plan) + # @param security_id [Integer] The security ID + # @param provider [String] The provider name + def clear_plan_restriction(security_id, provider:) + Rails.cache.delete(plan_restriction_cache_key(provider, security_id)) + end + + # Returns the plan restriction info for a security, or nil if none + # @param security_id [Integer] The security ID + # @param provider [String] The provider name + def plan_restriction_for(security_id, provider:) + Rails.cache.read(plan_restriction_cache_key(provider, security_id)) + end + + # Returns all plan-restricted securities from a collection of security IDs for a provider + # @param security_ids [Array] Security IDs to check + # @param provider [String] The provider name + # @return [Hash] security_id => restriction_info + def plan_restrictions_for(security_ids, provider:) + return {} if security_ids.blank? + + restrictions = {} + security_ids.each do |id| + restriction = plan_restriction_for(id, provider: provider) + restrictions[id] = restriction if restriction.present? + end + restrictions + end + + # Checks if an error message indicates a plan upgrade is required for a provider + # @param error_message [String] The error message + # @param provider [String] The provider name + def plan_upgrade_required?(error_message, provider:) + provider_class = PROVIDER_CLASSES[provider] + return false unless provider_class&.respond_to?(:plan_upgrade_required?) + provider_class.plan_upgrade_required?(error_message) + end + + private + + def plan_restriction_cache_key(provider, security_id) + "#{CACHE_KEY_PREFIX}/#{provider.downcase}/#{security_id}" + end + end +end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index def62fc03..bc5840c0c 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -111,9 +111,20 @@ class Security::Price::Importer ) if response.success? + Security.clear_plan_restriction(security.id, provider: security_provider.class.name.demodulize) response.data.index_by(&:date) else - 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}") + error_message = 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: #{error_message}") + + if Security.plan_upgrade_required?(error_message, provider: security_provider.class.name.demodulize) + Security.record_plan_restriction( + security_id: security.id, + error_message: error_message, + provider: security_provider.class.name.demodulize + ) + end + Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope| scope.set_tags(security_id: security.id) scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index 90b524f2f..b6275491a 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -60,6 +60,34 @@ <%= t(".plan", plan: @twelve_data_usage.data.plan) %>

+ + <% if @plan_restricted_securities.present? %> +
+
+ <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> +
+

<%= t(".plan_upgrade_warning_title") %>

+

<%= t(".plan_upgrade_warning_description") %>

+
    + <% @plan_restricted_securities.each do |security| %> +
  • + <%= security[:ticker] %> + <% if security[:name].present? %> + (<%= security[:name] %>) + <% end %> + — <%= t(".requires_plan", plan: security[:required_plan]) %> +
  • + <% end %> +
+

+ + <%= t(".view_pricing") %> + +

+
+
+
+ <% end %> <% end %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index d70260ba3..8f3fcec32 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -72,6 +72,10 @@ en: label: API Key placeholder: Enter your API key here plan: "%{plan} plan" + plan_upgrade_warning_title: Some tickers require a paid plan + plan_upgrade_warning_description: The following tickers in your portfolio cannot sync prices with your current Twelve Data plan. + requires_plan: requires %{plan} plan + view_pricing: View Twelve Data pricing title: Twelve Data update: failure: Invalid setting value diff --git a/test/models/security/plan_restriction_tracker_test.rb b/test/models/security/plan_restriction_tracker_test.rb new file mode 100644 index 000000000..264957980 --- /dev/null +++ b/test/models/security/plan_restriction_tracker_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class Security::PlanRestrictionTrackerTest < ActiveSupport::TestCase + setup do + # Use memory store for testing + @original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown do + Rails.cache = @original_cache + end + + test "plan_upgrade_required? detects Grow plan message" do + message = "This endpoint is available starting with Grow subscription." + assert Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? detects Pro plan message" do + message = "API error (code: 400): available starting with Pro plan" + assert Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for other errors" do + message = "Some other error message" + assert_not Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for nil" do + assert_not Security.plan_upgrade_required?(nil, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for unknown provider" do + message = "This endpoint is available starting with Grow subscription." + assert_not Security.plan_upgrade_required?(message, provider: "UnknownProvider") + end + + test "record_plan_restriction stores restriction in cache" do + Security.record_plan_restriction( + security_id: 999, + error_message: "This endpoint is available starting with Grow subscription.", + provider: "TwelveData" + ) + + restriction = Security.plan_restriction_for(999, provider: "TwelveData") + assert_not_nil restriction + assert_equal "Grow", restriction[:required_plan] + assert_equal "TwelveData", restriction[:provider] + end + + test "clear_plan_restriction removes restriction from cache" do + Security.record_plan_restriction( + security_id: 999, + error_message: "available starting with Pro", + provider: "TwelveData" + ) + + Security.clear_plan_restriction(999, provider: "TwelveData") + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + end + + test "plan_restrictions_for returns multiple restrictions" do + Security.record_plan_restriction(security_id: 1001, error_message: "available starting with Grow", provider: "TwelveData") + Security.record_plan_restriction(security_id: 1002, error_message: "available starting with Pro", provider: "TwelveData") + + restrictions = Security.plan_restrictions_for([ 1001, 1002, 9999 ], provider: "TwelveData") + + assert_equal 2, restrictions.keys.count + assert_equal "Grow", restrictions[1001][:required_plan] + assert_equal "Pro", restrictions[1002][:required_plan] + assert_nil restrictions[9999] + end + + test "plan_restrictions_for returns empty hash for empty input" do + assert_equal({}, Security.plan_restrictions_for([], provider: "TwelveData")) + assert_equal({}, Security.plan_restrictions_for(nil, provider: "TwelveData")) + end + + test "record_plan_restriction does nothing for non-plan errors" do + Security.record_plan_restriction( + security_id: 999, + error_message: "Some other error", + provider: "TwelveData" + ) + + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + end + + test "restrictions are scoped by provider" do + # Record restriction for TwelveData + Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData") + + # Simulate a different provider by directly writing to cache (tests cache key scoping) + Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days) + + twelve_data_restriction = Security.plan_restriction_for(999, provider: "TwelveData") + other_restriction = Security.plan_restriction_for(999, provider: "OtherProvider") + + assert_equal "Grow", twelve_data_restriction[:required_plan] + assert_equal "Pro", other_restriction[:required_plan] + end + + test "clearing restriction for one provider does not affect another" do + # Record restriction for TwelveData + Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData") + + # Simulate another provider by directly writing to cache + Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days) + + Security.clear_plan_restriction(999, provider: "TwelveData") + + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + assert_not_nil Security.plan_restriction_for(999, provider: "OtherProvider") + end +end