diff --git a/app/controllers/issue/exchange_rate_provider_missings_controller.rb b/app/controllers/issue/exchange_rate_provider_missings_controller.rb new file mode 100644 index 00000000..91cac7f5 --- /dev/null +++ b/app/controllers/issue/exchange_rate_provider_missings_controller.rb @@ -0,0 +1,19 @@ +class Issue::ExchangeRateProviderMissingsController < ApplicationController + before_action :set_issue, only: :update + + def update + Setting.synth_api_key = exchange_rate_params[:synth_api_key] + @issue.issuable.sync_later + redirect_back_or_to account_path(@issue.issuable) + 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 diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb new file mode 100644 index 00000000..0585446d --- /dev/null +++ b/app/controllers/issues_controller.rb @@ -0,0 +1,13 @@ +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 diff --git a/app/models/account.rb b/app/models/account.rb index e71390d6..9490f0f3 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,5 @@ class Account < ApplicationRecord - include Syncable - include Monetizable + include Syncable, Monetizable, Issuable validates :name, :balance, :currency, presence: true @@ -15,6 +14,7 @@ class Account < ApplicationRecord has_many :balances, dependent: :destroy has_many :imports, dependent: :destroy has_many :syncs, dependent: :destroy + has_many :issues, as: :issuable, dependent: :destroy monetize :balance diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb index 1756edc3..69748643 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/account/balance/syncer.rb @@ -1,9 +1,6 @@ class Account::Balance::Syncer - attr_reader :warnings - def initialize(account, start_date: nil) @account = account - @warnings = [] @sync_start_date = calculate_sync_start_date(start_date) end @@ -20,6 +17,8 @@ class Account::Balance::Syncer account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance end end + rescue Money::ConversionError => e + account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ]) end private @@ -67,20 +66,26 @@ class Account::Balance::Syncer from_currency = account.currency to_currency = account.family.currency + if ExchangeRate.exchange_rates_provider.nil? + account.observe_missing_exchange_rate_provider + return [] + end + exchange_rates = ExchangeRate.find_rates from: from_currency, to: to_currency, start_date: sync_start_date + missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date) + + if missing_exchange_rates.any? + account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates) + return [] + end + balances.map do |balance| exchange_rate = exchange_rates.find { |er| er.date == balance.date } - - raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate - build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) end - rescue Money::ConversionError - @warnings << "missing exchange rates from #{from_currency} to #{to_currency}" - [] end def build_balance(date, balance, currency = nil) diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb index e3479e88..bccfaf9c 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/account/holding/syncer.rb @@ -1,9 +1,6 @@ class Account::Holding::Syncer - attr_reader :warnings - def initialize(account, start_date: nil) @account = account - @warnings = [] @sync_date_range = calculate_sync_start_date(start_date)..Date.current @portfolio = {} @@ -69,6 +66,8 @@ class Account::Holding::Syncer price = get_cached_price(ticker, date) || trade_price + account.observe_missing_price(ticker:, date:) unless price + account.holdings.build \ date: date, security_id: holding[:security_id], diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb index 721399b3..adc26843 100644 --- a/app/models/account/sync.rb +++ b/app/models/account/sync.rb @@ -16,34 +16,25 @@ class Account::Sync < ApplicationRecord def run start! + account.resolve_stale_issues + sync_balances sync_holdings complete! rescue StandardError => error + account.observe_unknown_issue(error) fail! error end private def sync_balances - syncer = Account::Balance::Syncer.new(account, start_date: start_date) - - syncer.run - - append_warnings(syncer.warnings) + Account::Balance::Syncer.new(account, start_date: start_date).run end def sync_holdings - syncer = Account::Holding::Syncer.new(account, start_date: start_date) - - syncer.run - - append_warnings(syncer.warnings) - end - - def append_warnings(new_warnings) - update! warnings: warnings + new_warnings + Account::Holding::Syncer.new(account, start_date: start_date).run end def start! @@ -53,12 +44,17 @@ class Account::Sync < ApplicationRecord def complete! update! status: "completed" - broadcast_result type: "notice", message: "Sync complete" + + if account.has_issues? + broadcast_result type: "alert", message: account.highest_priority_issue.title + else + broadcast_result type: "notice", message: "Sync complete" + end end def fail!(error) update! status: "failed", error: error.message - broadcast_result type: "alert", message: error.message + broadcast_result type: "alert", message: I18n.t("account.sync.failed") end def broadcast_start @@ -78,6 +74,7 @@ class Account::Sync < ApplicationRecord partial: "shared/notification", locals: { type: type, message: message } ) + account.family.broadcast_refresh end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb new file mode 100644 index 00000000..8ffe87d3 --- /dev/null +++ b/app/models/concerns/issuable.rb @@ -0,0 +1,58 @@ +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 observe_missing_price(ticker:, date:) + issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil) + issue.append_missing_price(ticker, date) + issue.save! + 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 diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index b9be68b0..fcc8a80f 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -21,10 +21,12 @@ module Providable private def synth_provider - @synth_provider ||= begin - api_key = ENV["SYNTH_API_KEY"] - api_key.present? ? Provider::Synth.new(api_key) : nil - end + api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] + api_key.present? ? Provider::Synth.new(api_key) : nil + end + + def self_hosted? + Rails.application.config.app_mode.self_hosted? end end end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index b14bfbdc..1927e372 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -4,6 +4,10 @@ module ExchangeRate::Provided include Providable class_methods do + def provider_healthy? + exchange_rates_provider.present? && exchange_rates_provider.healthy? + end + private def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false) diff --git a/app/models/family.rb b/app/models/family.rb index 2aaeaf14..d98debb5 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -8,6 +8,7 @@ class Family < ApplicationRecord has_many :imports, through: :accounts has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy + has_many :issues, through: :accounts def snapshot(period = Period.all) query = accounts.active.joins(:balances) diff --git a/app/models/help/article.rb b/app/models/help/article.rb deleted file mode 100644 index 40f79474..00000000 --- a/app/models/help/article.rb +++ /dev/null @@ -1,54 +0,0 @@ -class Help::Article - attr_reader :frontmatter, :content - - def initialize(frontmatter:, content:) - @frontmatter = frontmatter - @content = content - end - - def title - frontmatter["title"] - end - - def html - render_markdown(content) - end - - class << self - def root_path - Rails.root.join("docs", "help") - end - - def find(slug) - Dir.glob(File.join(root_path, "*.md")).each do |file_path| - file_content = File.read(file_path) - frontmatter, markdown_content = parse_frontmatter(file_content) - - return new(frontmatter:, content: markdown_content) if frontmatter["slug"] == slug - end - - nil - end - - private - - def parse_frontmatter(content) - if content =~ /\A---(.+?)---/m - frontmatter = YAML.safe_load($1) - markdown_content = content[($~.end(0))..-1].strip - else - frontmatter = {} - markdown_content = content - end - - [ frontmatter, markdown_content ] - end - end - - private - - def render_markdown(content) - markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML) - markdown.render(content) - end -end diff --git a/app/models/issue.rb b/app/models/issue.rb new file mode 100644 index 00000000..0f0cf2d2 --- /dev/null +++ b/app/models/issue.rb @@ -0,0 +1,35 @@ +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 diff --git a/app/models/issue/exchange_rate_provider_missing.rb b/app/models/issue/exchange_rate_provider_missing.rb new file mode 100644 index 00000000..72411990 --- /dev/null +++ b/app/models/issue/exchange_rate_provider_missing.rb @@ -0,0 +1,9 @@ +class Issue::ExchangeRateProviderMissing < Issue + def default_severity + :error + end + + def stale? + ExchangeRate.provider_healthy? + end +end diff --git a/app/models/issue/exchange_rates_missing.rb b/app/models/issue/exchange_rates_missing.rb new file mode 100644 index 00000000..1527fec5 --- /dev/null +++ b/app/models/issue/exchange_rates_missing.rb @@ -0,0 +1,15 @@ +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 diff --git a/app/models/issue/prices_missing.rb b/app/models/issue/prices_missing.rb new file mode 100644 index 00000000..823c1d19 --- /dev/null +++ b/app/models/issue/prices_missing.rb @@ -0,0 +1,22 @@ +class Issue::PricesMissing < Issue + store_accessor :data, :missing_prices + + validates :missing_prices, presence: true + + def append_missing_price(ticker, date) + missing_prices[ticker] ||= [] + missing_prices[ticker] << date + end + + def stale? + stale = true + missing_prices.each do |ticker, dates| + oldest_date = dates.min + expected_price_count = (oldest_date..Date.current).count + prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date) + stale = false if prices.count < expected_price_count + end + + stale + end +end diff --git a/app/models/issue/unknown.rb b/app/models/issue/unknown.rb new file mode 100644 index 00000000..d232ebcb --- /dev/null +++ b/app/models/issue/unknown.rb @@ -0,0 +1,11 @@ +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 diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 6a86543e..8a0a8302 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -5,6 +5,11 @@ class Provider::Synth @api_key = api_key end + def healthy? + response = client.get("#{base_url}/user") + JSON.parse(response.body).dig("id").present? + end + def fetch_security_prices(ticker:, start_date:, end_date:) prices = paginate( "#{base_url}/tickers/#{ticker}/open-close", diff --git a/app/models/setting.rb b/app/models/setting.rb index 88cfdad1..9ecadd37 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -20,6 +20,8 @@ class Setting < RailsSettings::Base field :app_domain, type: :string, default: ENV["APP_DOMAIN"] field :email_sender, type: :string, default: ENV["EMAIL_SENDER"] + field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] + scope :smtp_settings do field :smtp_host, type: :string, read_only: true, default: ENV["SMTP_ADDRESS"] field :smtp_port, type: :string, read_only: true, default: ENV["SMTP_PORT"] diff --git a/app/views/accounts/_alert.html.erb b/app/views/accounts/_alert.html.erb deleted file mode 100644 index 697f0942..00000000 --- a/app/views/accounts/_alert.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<%# locals: (message:, help_path: nil) -%> -<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 bg-error/5", - data: { controller: "element-removal" }, - role: "alert" do %> -
- <%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %> -

<%= message %>

-
- -
- <% if help_path %> - <%= link_to "Troubleshoot", help_path, class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %> - <% end %> - - <%= tag.button data: { action: "click->element-removal#remove" } do %> - <%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-red-500") %> - <% end %> -
-<% end %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index c794140d..6da5ab9b 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -45,8 +45,8 @@ - <% if @account.alert %> - <%= render "alert", message: @account.alert, help_path: help_article_path("troubleshooting") %> + <% if @account.highest_priority_issue %> + <%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %> <% end %>
diff --git a/app/views/help/articles/show.html.erb b/app/views/help/articles/show.html.erb deleted file mode 100644 index d2a9eeff..00000000 --- a/app/views/help/articles/show.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= drawer do %> -
- <%= tag.h1 @article.title %> - - <%= sanitize(@article.html).html_safe %> -
-<% end %> diff --git a/app/views/issue/exchange_rate_provider_missings/show.html.erb b/app/views/issue/exchange_rate_provider_missings/show.html.erb new file mode 100644 index 00000000..a00cef56 --- /dev/null +++ b/app/views/issue/exchange_rate_provider_missings/show.html.erb @@ -0,0 +1,28 @@ +<%= content_for :title, @issue.title %> + +<%= content_for :description do %> +

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.

+ +

We have detected that your exchange rates provider is not configured yet.

+<% end %> + +<%= content_for :action do %> + <% if self_hosted? %> +

To fix this issue, you need to provide an API key for your exchange rate provider.

+ +

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.

+ +

Once you have your API key, paste it below to configure it.

+ + <%= 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 %> +

Please contact the Maybe team.

+ <% end %> +<% end %> diff --git a/app/views/issue/exchange_rates_missings/show.html.erb b/app/views/issue/exchange_rates_missings/show.html.erb new file mode 100644 index 00000000..5504ebae --- /dev/null +++ b/app/views/issue/exchange_rates_missings/show.html.erb @@ -0,0 +1,22 @@ +<%= content_for :title, @issue.title %> + +<%= content_for :description do %> +

Some exchange rates are missing for this account.

+ +
<%= JSON.pretty_generate(@issue.data) %>
+<% end %> + +<%= content_for :action do %> +

The Synth data provider could not find the requested data.

+ +

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.

+ +

Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the + following information:

+ + +<% end %> diff --git a/app/views/issue/unknowns/show.html.erb b/app/views/issue/unknowns/show.html.erb new file mode 100644 index 00000000..a6ba2084 --- /dev/null +++ b/app/views/issue/unknowns/show.html.erb @@ -0,0 +1,23 @@ +<%= content_for :title, @issue.title %> + +<%= content_for :description do %> +

An unknown issue has occurred.

+ +
<%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %>
+<% end %> + +<%= content_for :action do %> +

There is no fix for this issue yet.

+ +

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:

+ + + +

If there is data shown in the code block above that you think might be helpful, please include it in your + report.

+<% end %> diff --git a/app/views/issues/_issue.html.erb b/app/views/issues/_issue.html.erb new file mode 100644 index 00000000..cc80fbb6 --- /dev/null +++ b/app/views/issues/_issue.html.erb @@ -0,0 +1,14 @@ +<%# locals: (issue:) %> + +<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %> + +<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %> +
+ <%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %> +

<%= issue.title %>

+
+ +
+ <%= link_to "Troubleshoot", issue_path(issue), class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %> +
+<% end %> diff --git a/app/views/layouts/issues.html.erb b/app/views/layouts/issues.html.erb new file mode 100644 index 00000000..1a606279 --- /dev/null +++ b/app/views/layouts/issues.html.erb @@ -0,0 +1,15 @@ +<%= drawer do %> +
+ <%= tag.h2 do %> + <%= yield :title %> + <% end %> + + <%= tag.h3 t(".description") %> + <%= yield :description %> + + <%= tag.h3 t(".action") %> + <%= yield :action %> +
+<% end %> + +<%= render template: "layouts/application" %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index b6e8ef70..b0e688cd 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,6 +1,29 @@ { "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": "" + } ], - "updated": "2024-08-09 10:16:00 -0400", + "updated": "2024-08-16 10:19:50 -0400", "brakeman_version": "6.1.2" } diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 9b2ac779..7e58f1f3 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -25,7 +25,7 @@ search: - app/assets/builds ignore_unused: - 'activerecord.attributes.*' # i18n-tasks does not detect these on forms, forms validations (https://github.com/glebm/i18n-tasks/blob/0b4b483c82664f26c5696fb0f6aa1297356e4683/templates/config/i18n-tasks.yml#L146) - - 'activerecord.models.account*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human) + - 'activerecord.models.*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human) - 'helpers.submit.*' # i18n-tasks does not detect used at forms - 'helpers.label.*' # i18n-tasks does not detect used at forms - 'accounts.show.sync_message_*' # messages generated in the sync ActiveJob diff --git a/config/locales/models/account/sync/en.yml b/config/locales/models/account/sync/en.yml new file mode 100644 index 00000000..324ea42e --- /dev/null +++ b/config/locales/models/account/sync/en.yml @@ -0,0 +1,5 @@ +--- +en: + account: + sync: + failed: Sync failed diff --git a/config/locales/models/issue/en.yml b/config/locales/models/issue/en.yml new file mode 100644 index 00000000..b23fac8f --- /dev/null +++ b/config/locales/models/issue/en.yml @@ -0,0 +1,7 @@ +--- +en: + activerecord: + models: + issue/exchange_rate_provider_missing: Exchange rate provider missing + issue/exchange_rates_missing: Exchange rates missing + issue/unknown: Unknown issue occurred diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 4b2d7ece..0e847ab0 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -8,6 +8,9 @@ en: sign_up: create an account terms_of_service: Terms of Service your_account: Your account + issues: + action: How to fix this issue + description: Issue Description sidebar: accounts: Accounts dashboard: Dashboard diff --git a/config/routes.rb b/config/routes.rb index 15c00c9f..bbf8c8ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,12 @@ Rails.application.routes.draw do resources :institutions, except: %i[ index show ] + resources :issues, only: :show + + namespace :issue do + resources :exchange_rate_provider_missings, only: :update + end + # For managing self-hosted upgrades and release notifications resources :upgrades, only: [] do member do diff --git a/config/tailwind.config.js b/config/tailwind.config.js index cdb2c99d..aa7cbf2e 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -52,27 +52,17 @@ module.exports = { to: { "stroke-dashoffset": 0 }, }, }, - typography: { + typography: (theme) => ({ DEFAULT: { css: { maxWidth: "none", - a: { - color: "inherit", - textDecoration: "underline", - }, h2: { - fontSize: "1.125rem", - fontWeight: "inherit", - lineHeight: "1.75rem", - marginBottom: "0.625rem", - marginTop: "0.875rem", + fontSize: theme("fontSize.xl"), + fontWeight: theme("fontWeight.medium"), }, - p: { - marginBottom: "0.625rem", - marginTop: "0.875rem", - }, - strong: { - color: "inherit", + h3: { + fontSize: theme("fontSize.lg"), + fontWeight: theme("fontWeight.medium"), }, li: { margin: 0, @@ -94,7 +84,7 @@ module.exports = { }, }, }, - }, + }), }, }, plugins: [ diff --git a/db/migrate/20240815125404_create_issues.rb b/db/migrate/20240815125404_create_issues.rb new file mode 100644 index 00000000..cb28ce95 --- /dev/null +++ b/db/migrate/20240815125404_create_issues.rb @@ -0,0 +1,14 @@ +class CreateIssues < ActiveRecord::Migration[7.2] + def change + create_table :issues, id: :uuid do |t| + t.references :issuable, type: :uuid, polymorphic: true + t.string :type + t.integer :severity + t.datetime :last_observed_at + t.datetime :resolved_at + t.jsonb :data + + t.timestamps + end + end +end diff --git a/db/migrate/20240815190722_remove_warnings_from_sync.rb b/db/migrate/20240815190722_remove_warnings_from_sync.rb new file mode 100644 index 00000000..6e41ded1 --- /dev/null +++ b/db/migrate/20240815190722_remove_warnings_from_sync.rb @@ -0,0 +1,5 @@ +class RemoveWarningsFromSync < ActiveRecord::Migration[7.2] + def change + remove_column :account_syncs, :warnings, :text, array: true, default: [] + end +end diff --git a/db/schema.rb b/db/schema.rb index 10e656f9..8c88d122 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_08_13_170608) do +ActiveRecord::Schema[7.2].define(version: 2024_08_15_190722) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -69,7 +69,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_13_170608) do t.date "start_date" t.datetime "last_ran_at" t.string "error" - t.text "warnings", default: [], array: true t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_syncs_on_account_id" @@ -119,7 +118,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_13_170608) do t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["institution_id"], name: "index_accounts_on_institution_id" @@ -318,6 +317,19 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_13_170608) 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 diff --git a/docs/help/placeholder.md b/docs/help/placeholder.md deleted file mode 100644 index 3ede4e8b..00000000 --- a/docs/help/placeholder.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Troubleshooting -slug: troubleshooting ---- - -Coming soon... - -We're working on new guides to help troubleshoot various issues within the app. - -Help us out by reporting [issues on Github](https://github.com/maybe-finance/maybe/issues). diff --git a/lib/money.rb b/lib/money.rb index bee8a8e6..9aeb0d75 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -3,6 +3,17 @@ class Money include ActiveModel::Validations class ConversionError < StandardError + attr_reader :from_currency, :to_currency, :date + + def initialize(from_currency:, to_currency:, date:) + @from_currency = from_currency + @to_currency = to_currency + @date = date + + error_message = message || "Couldn't find exchange rate from #{from_currency} to #{to_currency} on #{date}" + + super(error_message) + end end attr_reader :amount, :currency, :store @@ -37,7 +48,7 @@ class Money else exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate - raise ConversionError.new("Couldn't find exchange rate from #{iso_code} to #{other_iso_code} on #{date}") unless exchange_rate + raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate Money.new(amount * exchange_rate, other_iso_code) end diff --git a/test/controllers/help/articles_controller_test.rb b/test/controllers/help/articles_controller_test.rb deleted file mode 100644 index 42f4998d..00000000 --- a/test/controllers/help/articles_controller_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "test_helper" - -class Help::ArticlesControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in @user = users(:family_admin) - - @article = Help::Article.new(frontmatter: { title: "Test Article", slug: "test-article" }, content: "") - - Help::Article.stubs(:find).returns(@article) - end - - test "can view help article" do - get help_article_path(@article) - - assert_response :success - assert_dom "h1", text: @article.title, count: 1 - end -end diff --git a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb new file mode 100644 index 00000000..10cb6f1a --- /dev/null +++ b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb @@ -0,0 +1,19 @@ +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: AccountSyncJob + assert_redirected_to account_url(@issue.issuable) + end +end diff --git a/test/controllers/issues_controller_test.rb b/test/controllers/issues_controller_test.rb new file mode 100644 index 00000000..6ba1705f --- /dev/null +++ b/test/controllers/issues_controller_test.rb @@ -0,0 +1,17 @@ +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 diff --git a/test/fixtures/account/syncs.yml b/test/fixtures/account/syncs.yml index 48c7fc4f..f042d3b1 100644 --- a/test/fixtures/account/syncs.yml +++ b/test/fixtures/account/syncs.yml @@ -4,7 +4,6 @@ one: start_date: 2024-07-07 last_ran_at: 2024-07-07 09:03:31 error: test sync error - warnings: [ "test warning 1", "test warning 2" ] two: account: investment diff --git a/test/fixtures/files/help_article.md b/test/fixtures/files/help_article.md deleted file mode 100644 index b2dc0b3d..00000000 --- a/test/fixtures/files/help_article.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Placeholder -slug: placeholder ---- - -Test help article \ No newline at end of file diff --git a/test/fixtures/issues.yml b/test/fixtures/issues.yml new file mode 100644 index 00000000..23ccd35d --- /dev/null +++ b/test/fixtures/issues.yml @@ -0,0 +1,5 @@ +one: + issuable: depository + issuable_type: Account + type: Issue::Unknown + last_observed_at: 2024-08-15 08:54:04 diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index df851688..d45e546e 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -4,7 +4,9 @@ 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 end test "exchange rate provider response contract" do diff --git a/test/interfaces/security_price_provider_interface_test.rb b/test/interfaces/security_price_provider_interface_test.rb index 97f2e9ad..c5642656 100644 --- a/test/interfaces/security_price_provider_interface_test.rb +++ b/test/interfaces/security_price_provider_interface_test.rb @@ -4,6 +4,7 @@ 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 diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb index b6048231..9bfeffcb 100644 --- a/test/models/account/balance/syncer_test.rb +++ b/test/models/account/balance/syncer_test.rb @@ -78,7 +78,9 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2) create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2) - run_sync_for(@account) + with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do + run_sync_for(@account) + end usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance) eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance) @@ -88,30 +90,29 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1 end - test "fails with error if exchange rate not available for any entry" do - create_transaction(account: @account, currency: "EUR") + test "raises issue if missing exchange rates" do + create_transaction(date: Date.current, account: @account, currency: "EUR") + + ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil) + @account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ]) syncer = Account::Balance::Syncer.new(@account) - with_env_overrides SYNTH_API_KEY: nil do - assert_raises Money::ConversionError do - syncer.run - end - end + syncer.run end # Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but # doesn't have exchange rates available to convert those calculated balances to the family currency - test "completes with warning if exchange rates not available to convert to family currency" do + test "observes issue if exchange rate provider is not configured" do @account.update! currency: "EUR" syncer = Account::Balance::Syncer.new(@account) + @account.expects(:observe_missing_exchange_rate_provider) + with_env_overrides SYNTH_API_KEY: nil do syncer.run end - - assert_equal 1, syncer.warnings.count end test "overwrites existing balances and purges stale balances" do diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb index fd917a0d..b95476ef 100644 --- a/test/models/account/holding/syncer_test.rb +++ b/test/models/account/holding/syncer_test.rb @@ -84,6 +84,8 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) ]) + @account.expects(:observe_missing_price).with(ticker: "AMZN", date: Date.current).once + run_sync_for(@account) assert_holdings(expected) diff --git a/test/models/account/issue_test.rb b/test/models/account/issue_test.rb new file mode 100644 index 00000000..58408f23 --- /dev/null +++ b/test/models/account/issue_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::IssueTest < ActiveSupport::TestCase + test "the truth" do + assert true + end +end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb index 1167a41d..75b6eaec 100644 --- a/test/models/account/sync_test.rb +++ b/test/models/account/sync_test.rb @@ -14,14 +14,11 @@ class Account::SyncTest < ActiveSupport::TestCase Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).once + @account.expects(:resolve_stale_issues).once @balance_syncer.expects(:run).once - @balance_syncer.expects(:warnings).returns([ "test balance sync warning" ]).once - @holding_syncer.expects(:run).once - @holding_syncer.expects(:warnings).returns([ "test holding sync warning" ]).once assert_equal "pending", @sync.status - assert_equal [], @sync.warnings assert_nil @sync.last_ran_at @sync.run @@ -29,7 +26,6 @@ class Account::SyncTest < ActiveSupport::TestCase streams = capture_turbo_stream_broadcasts [ @account.family, :notifications ] assert_equal "completed", @sync.status - assert_equal [ "test balance sync warning", "test holding sync warning" ], @sync.warnings assert @sync.last_ran_at assert_equal "append", streams.first["action"] diff --git a/test/models/help/article_test.rb b/test/models/help/article_test.rb deleted file mode 100644 index eaac5377..00000000 --- a/test/models/help/article_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -require "test_helper" - -class Help::ArticleTest < ActiveSupport::TestCase - include ActiveJob::TestHelper - - setup do - Help::Article.stubs(:root_path).returns(Rails.root.join("test", "fixtures", "files")) - end - - test "returns nil if article not found" do - assert_nil Help::Article.find("missing") - end - - test "find and renders markdown article" do - article = Help::Article.find("placeholder") - - assert_equal "Placeholder", article.title - assert_equal "Test help article", article.content - assert_equal "

Test help article

\n", article.html - end -end diff --git a/test/models/issue_test.rb b/test/models/issue_test.rb new file mode 100644 index 00000000..7e15ee90 --- /dev/null +++ b/test/models/issue_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class IssueTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end