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 %>
-
- <% 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:
+
+
+ - What type of data is missing?
+ - Any other information you think might be helpful
+
+<% 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:
+
+
+ - Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %>
+ - Open an issue on
+ our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %>
+
+
+
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