Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix foreign account sync crash #794

Merged
merged 6 commits into from
May 27, 2024

Conversation

jakubkottnauer
Copy link
Contributor

Syncing foreign currency accounts currently crashes as soon as a new exchange rate needs to be fetched.

find_rate_or_fetch already saves the fetched rate to the database (https://github.com/maybe-finance/maybe/blob/main/app/models/exchange_rate.rb#L15) so this PR removes the duplicate save from the Syncable module

@zachgoll
Copy link
Collaborator

@jakubkottnauer I could be remembering this incorrectly, but wouldn't find_rate_or_fetch raise an error if the user has not configured an exchange rates provider?

I'm thinking we'll want to make sure that no errors are raised if the user hasn't yet configured the provider and go with the "warning" approach we were talking about here.

@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch from 93e0ca2 to 7ad98f0 Compare May 23, 2024 18:51
@jakubkottnauer
Copy link
Contributor Author

jakubkottnauer commented May 23, 2024

@zachgoll I went ahead and implemented a UI alert that shows up if the SYNTH_API_KEY env var hasn't been provided. I have added a Provider::Synth.initialized? and use it to show the alert:

Screenshot 2024-05-23 at 20 49 59

I have also added the use of Provider::Synth.initialized? into the sync call itself. This took me down a fun rabbit hole of failing tests, because in tests initialized? would always return false. That lead me to refactor the Synth provider to be properly configurable using the pattern described in this article, instead of depending on the env var directly. What do you think?

@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch from 7ad98f0 to ab8a9a4 Compare May 23, 2024 18:59
logger.error("Cancelling sync of foreign account #{id}: No exchange rate provider ready")
update!(status: "error")
return
end
Copy link
Collaborator

@zachgoll zachgoll May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of canceling a sync based on a condition here, but I think a foreign_currency? account should still be able to sync regardless of whether the exchange rates are available.

The reason being—as long as all the transactions/valuations are in the same currency, we can still successfully build a historical balance graph in that currency. It's only during those "rollup" scenarios where we are trying to combine balances of different currencies where we'd need to abort and handle.

All that said, multi_currency? accounts could benefit from cancelling the sync early if we don't have exchange rates. Because in that case, we cannot even build a historical balance graph properly without first converting values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on what exchange rates are available during a sync, there is a chance that an error is raised in the calculator here https://github.com/maybe-finance/maybe/blob/main/app/models/account/balance/calculator.rb#L54
which is why I went with short-circuiting the entire sync process.

If we're ok with not raising errors here and just generating invalid balances (together with showing the UI messages) then I can change this

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is a tricky one. To me this feels like the calculator logic I wrote is more at fault here than anything.

Ultimately, there are 2 stages of currency conversion going on in this calculator:

  1. Normalizing multi_currency? balances - if this fails, then we can't show anything for the account
  2. Converting an entire series of foreign_currency? balances - if this fails, we can't rollup the account, but we can still show a history graph in its native currency

We could either:

  1. Loop through balances and for balances where there is no exchange rate, we don't generate it at all (leaving balance gaps)
  2. Pre-fetch exchange rates for the known balances and if any exchange fails, we abort the entire conversion

The "all or nothing" approach in #2 seems a bit simpler here and in order to make that happen I think this calculator is what needs the refactor to decouple the balance logic from the exchange rate logic. The two are intermingled right now. For example:

def normalized_valuations
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
end
def normalized_transactions
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
end

Provider::Synth.configure do |config|
config.api_key = ENV["SYNTH_API_KEY"]
end
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue I think we'll run into here is for self-hosters. They'll need to be able to configure their Synth API key directly in the UI via the settings model, which we haven't configured quite yet, but will need to soon.

You had mentioned that a big reason for this refactor is due to initialized? always returning false during the tests. I'm thinking that may be more of a symptom of tightly-coupled dependencies rather than a reason to change the class itself. I'm not sure which tests were failing on you, but I think we should try to address it elsewhere so we can keep the config for Synth available at runtime. Let me know which tests are causing problems and I'd be happy to throw around some ideas.

Something that may help out is the with_env_override helper, which allows us to toggle environment variables on and off during tests for special cases where we need to test "enabled/disabled" behaviors:

def with_env_overrides(overrides = {}, &block)
ClimateControl.modify(**overrides, &block)
end

Here's an example usage:

with_env_overrides REQUIRE_INVITE_CODE: "true" do

Copy link
Contributor Author

@jakubkottnauer jakubkottnauer May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was specifically the sync tests that were failing because of the new early return we discuss in the other thread. If we were to remove the early return and instead proceed with the sync generating possibly invalid balances, then that would also remove the immediate need for this refactor. Though I still think it's beneficial to have the provider configurable in the same way as if it was a 3rd party gem (this seems to be quite a common pattern from what I've gathered).

but I think we should try to address it elsewhere so we can keep the config for Synth available at runtime

Doesn't the refactor already solve it? Maybe I'm missing something but I think with the refactor you can access/modify the synth configuration from anywhere at any time

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this does allow for configuration at runtime, but it would be more of a "re-configuration" (initializes, then user changes the api key in their settings), which feels like an extra layer of abstraction (global state) that is not necessary.

Here is the original data providers PR where I had started down this "global state" path and shows how we ended up with the current design which is more tightly integrated with the domain models.

I think rather than focusing on configuration and the initialized? method that checks whether an API key is present (which still doesn't guarantee rates are present), we need to dig into our calculator class and refactor the logic to make these decisions based on the presence/absence of the exchange rates as I mentioned in the other comment.

In other words, if we're syncing foreign currency account XYZ, we need to figure out which exchange rates are needed, check if we have all of them available (regardless of providers as they could be in our database), and then proceed/abort the sync depending on that response.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then for the UI warnings/errors, we can check on an account-by-account basis by populating these during the sync process (these are currently not in use, but I had added as placeholders):

maybe/db/schema.rb

Lines 93 to 94 in 6e59fdb

t.jsonb "sync_warnings", default: "[]", null: false
t.jsonb "sync_errors", default: "[]", null: false

That way, we can simply say:

<% if @account.sync_errors.present? %>
  <%= render partial: "accounts/some-partial", locals: { errors: @account.sync_errors } %>
<% end %>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply! All this makes sense, I'll rework the PR 👍

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jakubkottnauer feel free to rework the sync_warnings and sync_errors if you need to. We may only need one of these? And could potentially be worth a dedicated table so we can provide more structured warnings/errors?

I was thinking that long term, we'd have a folder in the repo with the following format:

/docs/troubleshooting/[error_type]/[error_name].md

And then we'd be able to write some guides for each error that could be rendered in the sidebar drawer or something.

Not necessarily needed yet, just wanted to share that idea if it helps for this

@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch from ab8a9a4 to b328230 Compare May 24, 2024 21:02
@jakubkottnauer jakubkottnauer marked this pull request as draft May 24, 2024 21:02
@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch 2 times, most recently from 4f7df05 to 5e8114f Compare May 26, 2024 11:23
@jakubkottnauer jakubkottnauer marked this pull request as ready for review May 26, 2024 11:24
@jakubkottnauer
Copy link
Contributor Author

jakubkottnauer commented May 26, 2024

@zachgoll Ready for another round of review. I tried to keep the changes to a bare minimum so I have mostly left the sync_warnings and sync_errors the way they were, just fixed a few related bugs I found along the way.

@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch from 5e8114f to a6c6653 Compare May 26, 2024 11:35
end

def get_rate_series(from, to, date_range)
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
def get_rates(from, to, dates)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the method as it can be used not just for a continuous series of dates, but also for an arbitrary array of dates

Copy link
Collaborator

@zachgoll zachgoll left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jakubkottnauer nice updates here, I think this is a good improvement on what we had.

A few notes that could be good to keep in mind moving forward:

  • In the future, we'll probably have to introduce some sort of UI that warns the user that they will be using API credits for a sync process (not needed yet)
  • As I'm looking through the errors vs. warnings, my guess is that we'll probably end up with a single errors array with varying severities. I'm thinking that it might be adding a bit of ambiguity to our sync process trying to decide whether something is an "error" or "warning". Not something we need to update here, but just a thought moving forward.

<%# locals: (type: "error", content: "") -%>
<% color = type == "error" ? "red" : "yellow" %>
<%= content_tag :div,
class: "flex justify-between bg-#{color}-50 rounded-xl p-3",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Tailwind does static analysis of the classes, bg-#{color}-50 won't always work properly:

https://tailwindcss.com/docs/content-configuration#dynamic-class-names

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point, I always forget about this 🙇

<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
<p class="text-sm"><%= content %></p>
</div>
<%= content_tag :a, lucide_icon("x", class: "w-5 h-5 shrink-0 text-#{color}-500"), data: { action: "click->element-removal#remove" }, class:"flex gap-1 font-medium items-center text-gray-900 px-3 py-1.5 rounded-lg cursor-pointer" %>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above for text-#{color}-500

@jakubkottnauer jakubkottnauer force-pushed the fix-foreign-account-sync branch from a6c6653 to 6534541 Compare May 27, 2024 15:31
@jakubkottnauer jakubkottnauer requested a review from zachgoll May 27, 2024 15:32
@zachgoll zachgoll merged commit 483d678 into maybe-finance:main May 27, 2024
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants