diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 222ae018e39..97b8de92fc5 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -26,6 +26,10 @@ def update Setting.synth_api_key = hosting_params[:synth_api_key] end + if hosting_params.key?(:data_enrichment_enabled) + Setting.data_enrichment_enabled = hosting_params[:data_enrichment_enabled] + end + redirect_to settings_hosting_path, notice: t(".success") rescue ActiveRecord::RecordInvalid => error flash.now[:alert] = t(".failure") @@ -34,7 +38,7 @@ def update private def hosting_params - params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key) + params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key, :data_enrichment_enabled) end def raise_if_not_self_hosted diff --git a/app/jobs/enrich_data_job.rb b/app/jobs/enrich_data_job.rb new file mode 100644 index 00000000000..97286b8237e --- /dev/null +++ b/app/jobs/enrich_data_job.rb @@ -0,0 +1,7 @@ +class EnrichDataJob < ApplicationJob + queue_as :default + + def perform(account) + account.enrich_data + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 05931b7b42b..400e8beaa73 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -126,6 +126,14 @@ def favorable_direction classification == "asset" ? "up" : "down" end + def enrich_data + DataEnricher.new(self).run + end + + def enrich_data_later + EnrichDataJob.perform_later(self) + end + def update_with_sync!(attributes) transaction do update!(attributes) diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb new file mode 100644 index 00000000000..4beb0b9294c --- /dev/null +++ b/app/models/account/data_enricher.rb @@ -0,0 +1,61 @@ +class Account::DataEnricher + include Providable + + attr_reader :account + + def initialize(account) + @account = account + end + + def run + enrich_transactions + end + + private + def enrich_transactions + candidates = account.entries.account_transactions.includes(entryable: [ :merchant, :category ]) + + Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}") + + merchants = {} + categories = {} + + candidates.each do |entry| + if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil? + begin + info = self.class.synth_provider.enrich_transaction(entry.name).info + + next unless info.present? + + if info.name.present? + merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name) + + if info.icon_url.present? + merchant.icon_url = info.icon_url + end + end + + if info.category.present? + category = categories[info.category] ||= account.family.categories.find_or_create_by(name: info.category) + end + + entryable_attributes = { id: entry.entryable_id } + entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil? + entryable_attributes[:category_id] = category.id if category.present? && entry.entryable.category_id.nil? + + Account.transaction do + merchant.save! if merchant.present? + category.save! if category.present? + entry.update!( + enriched_at: Time.current, + name: entry.enriched_at.nil? ? info.name : entry.name, + entryable_attributes: entryable_attributes + ) + end + rescue => e + Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}") + end + end + end + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index d42ff431a0a..9160e64f550 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -10,6 +10,12 @@ def run account.reload update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency + + if Setting.data_enrichment_enabled || Rails.configuration.app_mode.managed? + account.enrich_data_later + else + Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") + end end private diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index 996efff8841..4a8de8c09c1 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -23,8 +23,10 @@ def git_repository_provider end def synth_provider - api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] - api_key.present? ? Provider::Synth.new(api_key) : nil + @synth_provider ||= begin + api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] + api_key.present? ? Provider::Synth.new(api_key) : nil + end end private diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index c212e992222..b7735575b7c 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -167,6 +167,35 @@ def fetch_security_info(ticker:, mic_code:) raw_response: response end + def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) + params = { + description: description, + amount: amount, + date: date, + city: city, + state: state, + country: country + }.compact + + response = client.get("#{base_url}/enrich", params) + + parsed = JSON.parse(response.body) + + EnrichTransactionResponse.new \ + info: EnrichTransactionInfo.new( + name: parsed.dig("merchant"), + icon_url: parsed.dig("icon"), + category: parsed.dig("category") + ), + success?: true, + raw_response: response + rescue StandardError => error + EnrichTransactionResponse.new \ + success?: false, + error: error, + raw_response: error + end + private attr_reader :api_key @@ -177,6 +206,8 @@ def fetch_security_info(ticker:, mic_code:) UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true + EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true + EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" diff --git a/app/models/setting.rb b/app/models/setting.rb index d576fbea02a..eb1a9369cbd 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -17,6 +17,10 @@ class Setting < RailsSettings::Base default: ENV.fetch("UPGRADES_TARGET", "release"), validates: { inclusion: { in: %w[release commit] } } + field :data_enrichment_enabled, + type: :boolean, + default: true + field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] field :require_invite_for_signup, type: :boolean, default: false diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index dc2a026ace3..9a37a958095 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -11,15 +11,17 @@
<%= merchant.name %>
diff --git a/app/views/settings/hostings/_data_enrichment_settings.html.erb b/app/views/settings/hostings/_data_enrichment_settings.html.erb new file mode 100644 index 00000000000..6d409923820 --- /dev/null +++ b/app/views/settings/hostings/_data_enrichment_settings.html.erb @@ -0,0 +1,18 @@ +<%= t(".title") %>
+<%= t(".description") %>
+