Skip to content

Commit

Permalink
Add account data enrichment (#1532)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zachgoll authored Dec 13, 2024
1 parent bac2e64 commit fe199f2
Show file tree
Hide file tree
Showing 16 changed files with 182 additions and 10 deletions.
6 changes: 5 additions & 1 deletion app/controllers/settings/hostings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/jobs/enrich_data_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class EnrichDataJob < ApplicationJob
queue_as :default

def perform(account)
account.enrich_data
end
end
8 changes: 8 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions app/models/account/data_enricher.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/models/account/syncer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/models/concerns/providable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions app/models/provider/synth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/models/setting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions app/views/account/transactions/_transaction.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@

<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= transaction.name.first.upcase %>
</div>
<% 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 %>

<div class="truncate">
<% 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" %>
Expand Down
9 changes: 8 additions & 1 deletion app/views/merchants/_merchant.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

<div class="flex justify-between items-center p-4 bg-white">
<div class="flex w-full items-center gap-2.5">
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
<% if merchant.icon_url %>
<div class="w-8 h-8 rounded-full flex justify-center items-center">
<%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %>
</div>
<% else %>
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
<% end %>

<p class="text-gray-900 text-sm truncate">
<%= merchant.name %>
</p>
Expand Down
18 changes: 18 additions & 0 deletions app/views/settings/hostings/_data_enrichment_settings.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm"><%= t(".title") %></p>
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
</div>

<%= 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| %>
<div class="relative inline-block select-none">
<%= 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, "&nbsp;".html_safe, class: "maybe-switch" %>
</div>
<% end %>
</div>
</div>
1 change: 1 addition & 0 deletions app/views/settings/hostings/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
</div>
<% end %>

Expand Down
3 changes: 3 additions & 0 deletions config/locales/views/settings/hostings/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions db/migrate/20241212141453_add_merchant_logo.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions test/jobs/enrich_data_job_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class EnrichDataJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

0 comments on commit fe199f2

Please sign in to comment.