1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Account Issue Model and Resolution Flow + Troubleshooting guides (#1090)

* Rough draft of issue system

* Simplify design

* Remove stale files from merge conflicts

* STI for issues

* Cleanup

* Improve Synth api key flow

* Stub api key for test
This commit is contained in:
Zach Gollwitzer 2024-08-16 12:13:48 -04:00 committed by GitHub
parent c70a08aca2
commit 707c5ca0ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 507 additions and 211 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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],

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

35
app/models/issue.rb Normal file
View file

@ -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

View file

@ -0,0 +1,9 @@
class Issue::ExchangeRateProviderMissing < Issue
def default_severity
:error
end
def stale?
ExchangeRate.provider_healthy?
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"]

View file

@ -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 %>
<div class="flex gap-3 items-center text-red-500 grow overflow-x-scroll">
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
<p class="text-sm whitespace-nowrap"><%= message %></p>
</div>
<div class="flex items-center gap-4 ml-auto">
<% 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 %>
</div>
<% end %>

View file

@ -45,8 +45,8 @@
</div>
</div>
<% 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 %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">

View file

@ -1,7 +0,0 @@
<%= drawer do %>
<div class="prose">
<%= tag.h1 @article.title %>
<%= sanitize(@article.html).html_safe %>
</div>
<% end %>

View file

@ -0,0 +1,28 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>You have set your family currency preference to <%= Current.family.currency %>. <%= @issue.issuable.name %> has
entries in another currency, which means we have to fetch exchange rates from a data provider to accurately show
historical results.</p>
<p>We have detected that your exchange rates provider is not configured yet.</p>
<% end %>
<%= content_for :action do %>
<% if self_hosted? %>
<p>To fix this issue, you need to provide an API key for your exchange rate provider.</p>
<p>Currently, we support <%= link_to "Synth Finance", "https://synthfinance.com", target: "_blank" %>, so you need
to
get a free API key from the link provided.</p>
<p>Once you have your API key, paste it below to configure it.</p>
<%= styled_form_with model: @issue, url: issue_exchange_rate_provider_missing_path(@issue), method: :patch, class: "space-y-3" do |form| %>
<%= form.text_field :synth_api_key, label: "Synth API Key", placeholder: "Synth API Key", type: "password", class: "w-full", value: Setting.synth_api_key %>
<%= form.submit "Save and Re-Sync Account", class: "btn-primary" %>
<% end %>
<% else %>
<p>Please contact the Maybe team.</p>
<% end %>
<% end %>

View file

@ -0,0 +1,22 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>Some exchange rates are missing for this account.</p>
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
<% end %>
<%= content_for :action do %>
<p>The Synth data provider could not find the requested data.</p>
<p>We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by
requesting the data you need.</p>
<p>Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the
following information:</p>
<ul>
<li>What type of data is missing?</li>
<li>Any other information you think might be helpful</li>
</ul>
<% end %>

View file

@ -0,0 +1,23 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>An unknown issue has occurred.</p>
<pre><code><%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %></code></pre>
<% end %>
<%= content_for :action do %>
<p>There is no fix for this issue yet.</p>
<p>Maybe is in active development and we value your feedback. There are a couple ways you can report this issue to
help us make Maybe better:</p>
<ul>
<li>Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %></li>
<li>Open an issue on
our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %></li>
</ul>
<p>If there is data shown in the code block above that you think might be helpful, please include it in your
report.</p>
<% end %>

View file

@ -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 %>
<div class="flex gap-3 items-center text-red-500 grow overflow-x-scroll">
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
<p class="text-sm whitespace-nowrap"><%= issue.title %></p>
</div>
<div class="flex items-center gap-4 ml-auto">
<%= link_to "Troubleshoot", issue_path(issue), class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %>
</div>
<% end %>

View file

@ -0,0 +1,15 @@
<%= drawer do %>
<article class="prose">
<%= tag.h2 do %>
<%= yield :title %>
<% end %>
<%= tag.h3 t(".description") %>
<%= yield :description %>
<%= tag.h3 t(".action") %>
<%= yield :action %>
</article>
<% end %>
<%= render template: "layouts/application" %>

View file

@ -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"
}

View file

@ -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

View file

@ -0,0 +1,5 @@
---
en:
account:
sync:
failed: Sync failed

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: [

View file

@ -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

View file

@ -0,0 +1,5 @@
class RemoveWarningsFromSync < ActiveRecord::Migration[7.2]
def change
remove_column :account_syncs, :warnings, :text, array: true, default: []
end
end

18
db/schema.rb generated
View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,6 +0,0 @@
---
title: Placeholder
slug: placeholder
---
Test help article

5
test/fixtures/issues.yml vendored Normal file
View file

@ -0,0 +1,5 @@
one:
issuable: depository
issuable_type: Account
type: Issue::Unknown
last_observed_at: 2024-08-15 08:54:04

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,7 @@
require "test_helper"
class Account::IssueTest < ActiveSupport::TestCase
test "the truth" do
assert true
end
end

View file

@ -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"]

View file

@ -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 "<p>Test help article</p>\n", article.html
end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class IssueTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end