From fe199f235784a96d9b25efe846d87e6f5fb010e7 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 13 Dec 2024 17:22:27 -0500 Subject: [PATCH] Add account data enrichment (#1532) * Add data enrichment * Make data enrichment optional for self-hosters * Add categories to data enrichment * Only update category and merchant if nil * Fix name overrides * Lint fixes --- .../settings/hostings_controller.rb | 6 +- app/jobs/enrich_data_job.rb | 7 +++ app/models/account.rb | 8 +++ app/models/account/data_enricher.rb | 61 +++++++++++++++++++ app/models/account/syncer.rb | 6 ++ app/models/concerns/providable.rb | 6 +- app/models/provider/synth.rb | 31 ++++++++++ app/models/setting.rb | 4 ++ .../transactions/_transaction.html.erb | 12 ++-- app/views/merchants/_merchant.html.erb | 9 ++- .../_data_enrichment_settings.html.erb | 18 ++++++ app/views/settings/hostings/show.html.erb | 1 + config/locales/views/settings/hostings/en.yml | 3 + .../20241212141453_add_merchant_logo.rb | 8 +++ db/schema.rb | 5 +- test/jobs/enrich_data_job_test.rb | 7 +++ 16 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 app/jobs/enrich_data_job.rb create mode 100644 app/models/account/data_enricher.rb create mode 100644 app/views/settings/hostings/_data_enrichment_settings.html.erb create mode 100644 db/migrate/20241212141453_add_merchant_logo.rb create mode 100644 test/jobs/enrich_data_job_test.rb 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 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> -
- <%= transaction.name.first.upcase %> -
+ <% if entry.account_transaction.merchant&.icon_url %> + <%= image_tag entry.account_transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> + <% else %> + <%= render "shared/circle_logo", name: entry.name, size: "sm" %> + <% end %>
<% if entry.new_record? %> - <%= content_tag :p, transaction.name %> + <%= content_tag :p, entry.name %> <% else %> - <%= link_to transaction.name, + <%= link_to entry.name, entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/merchants/_merchant.html.erb b/app/views/merchants/_merchant.html.erb index 2b964140c6c..a454d41af89 100644 --- a/app/views/merchants/_merchant.html.erb +++ b/app/views/merchants/_merchant.html.erb @@ -2,7 +2,14 @@
- <%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %> + <% if merchant.icon_url %> +
+ <%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %> +
+ <% else %> + <%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %> + <% end %> +

<%= 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") %>

+
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> +
+ <%= form.check_box :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> + <%= form.label :data_enrichment_enabled, " ".html_safe, class: "maybe-switch" %> +
+ <% end %> +
+
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index ba4b7d5d7ac..a2af0bed579 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -10,6 +10,7 @@ <%= render "settings/hostings/upgrade_settings" %> <%= render "settings/hostings/provider_settings" %> <%= render "settings/hostings/synth_settings" %> + <%= render "settings/hostings/data_enrichment_settings" %>
<% end %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 90a89fd7c95..6b34a6cd283 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -2,6 +2,9 @@ en: settings: hostings: + data_enrichment_settings: + description: Enable data enrichment for your accounts such as merchant info, transaction description cleanup, and more + title: Data Enrichment invite_code_settings: description: Every new user that joins your instance of Maybe can only do so via an invite code diff --git a/db/migrate/20241212141453_add_merchant_logo.rb b/db/migrate/20241212141453_add_merchant_logo.rb new file mode 100644 index 00000000000..81bd198c5bb --- /dev/null +++ b/db/migrate/20241212141453_add_merchant_logo.rb @@ -0,0 +1,8 @@ +class AddMerchantLogo < ActiveRecord::Migration[7.2] + def change + add_column :merchants, :icon_url, :string + add_column :merchants, :enriched_at, :datetime + + add_column :account_entries, :enriched_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 311837330c8..5fd3f26dfa3 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_12_07_002408) do +ActiveRecord::Schema[7.2].define(version: 2024_12_12_141453) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -48,6 +48,7 @@ t.text "notes" t.boolean "excluded", default: false t.string "plaid_id" + t.datetime "enriched_at" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" @@ -452,6 +453,8 @@ t.uuid "family_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "icon_url" + t.datetime "enriched_at" t.index ["family_id"], name: "index_merchants_on_family_id" end diff --git a/test/jobs/enrich_data_job_test.rb b/test/jobs/enrich_data_job_test.rb new file mode 100644 index 00000000000..067767f6a72 --- /dev/null +++ b/test/jobs/enrich_data_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EnrichDataJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end