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

Add account data enrichment #1532

Merged
merged 7 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading