1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Data provider simplification, tests, and documentation (#1997)

* Ignore env.test from source control

* Simplification of providers interface

* Synth tests

* Update money to use new find rates method

* Remove unused issues code

* Additional issue feature removals

* Update price data fetching and tests

* Update documentation for providers

* Security test fixes

* Fix self host test

* Update synth usage data access

* Remove AI pr schema changes
This commit is contained in:
Zach Gollwitzer 2025-03-17 11:54:53 -04:00 committed by GitHub
parent dd75cadebc
commit f65b93a352
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 2014 additions and 1638 deletions

View file

@ -1,6 +1,7 @@
---
description: This rule explains the system architecture and data flow of the Rails app
globs: *
alwaysApply: false
---
This file outlines how the codebase is structured and how data flows through the app.
@ -131,4 +132,177 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
## Data Providers
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
```rb
module Providers
module_function
def synth
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
end
```
There are two types of 3rd party data in the Maybe app:
1. "Concept" data
2. One-off data
### "Concept" data
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this:
```
app/models/
exchange_rate.rb # <- ActiveRecord model and "concept"
exchange_rate/
provided.rb # <- Chooses the provider for this concept based on user settings / config
provideable.rb # <- Defines interface for providing exchange rates
provider.rb # <- Base provider class
provider/
synth.rb # <- Concrete provider implementation
```
Where the `Provideable` and concrete provider implementations would be something like:
```rb
# Defines the interface an exchange rate provider must implement
module ExchangeRate::Provideable
extend ActiveSupport::Concern
FetchRateData = Data.define(:rate)
FetchRatesData = Data.define(:rates)
def fetch_exchange_rate(from:, to:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
end
end
```
Any provider that is a valid exchange rate provider must implement this interface:
```rb
class ConcreteProvider < Provider
include ExchangeRate::Provideable
def fetch_exchange_rate(from:, to:, date:)
provider_response do
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new # build response
)
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
# Implementation
end
end
```
### One-off data
For data that does not fit neatly into a "concept", a `Provideable` is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
```rb
class SomeModel < Application
def synth_usage
Providers.synth.usage
end
end
```
## "Provided" Concerns
In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
- Choosing the provider to use for this "concept"
- Providing convenience methods on the model for accessing data
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
```rb
module ExchangeRate::Provided
extend ActiveSupport::Concern
class_methods do
def provider
Providers.synth
end
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
# Implementation
end
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
# Implementation
end
end
end
```
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
```rb
def access_patterns_example
# Call exchange rate provider directly
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
# Call convenience method
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
end
```
## Concrete provider implementations
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object:
```rb
class ConcreteProvider < Provider
def fetch_some_data
provider_response do
ExampleData.new(
example: "data"
)
end
end
end
```
The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
```rb
class ConcreteProvider < Provider
def fetch_some_data
provider_response do
data = nil
# Raise an error if data cannot be returned
raise ProviderError.new("Could not find the data you need") if data.nil?
data
end
end
end
```