mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Remove manual merchant management (rules will replace)
This commit is contained in:
parent
f07940bf45
commit
83dcbd9ff0
44 changed files with 123 additions and 386 deletions
|
@ -1,7 +1,7 @@
|
|||
class Account::TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
permitted_entryable_attributes :id, :category_id, { tag_ids: [] }
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
|
@ -27,11 +27,11 @@ class Account::TransactionsController < ApplicationController
|
|||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
class MerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
@merchant = Current.family.merchants.new(merchant_params)
|
||||
|
||||
if @merchant.save
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
else
|
||||
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
|
@ -10,7 +10,6 @@ module SettingsHelper
|
|||
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
|
||||
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
|
||||
{ name: "Rules", path: :rules_path },
|
||||
{ name: I18n.t("settings.settings_nav.merchants_label"), path: :merchants_path },
|
||||
{ name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path },
|
||||
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
|
||||
]
|
||||
|
|
|
@ -8,7 +8,6 @@ class FamilyResetJob < ApplicationJob
|
|||
family.accounts.destroy_all
|
||||
family.categories.destroy_all
|
||||
family.tags.destroy_all
|
||||
family.merchants.destroy_all
|
||||
family.plaid_items.destroy_all
|
||||
family.imports.destroy_all
|
||||
family.budgets.destroy_all
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
module Account::Enrichable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def enrich_data
|
||||
total_unenriched = entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
batch_size = 50
|
||||
batches = (total_unenriched.to_f / batch_size).ceil
|
||||
|
||||
batches.times do |batch|
|
||||
EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size)
|
||||
# EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_transaction_batch(batch_size = 50, offset = 0)
|
||||
transactions_batch = enrichable_transactions.offset(offset).limit(batch_size)
|
||||
|
||||
Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})")
|
||||
|
||||
merchants = {}
|
||||
|
||||
transactions_batch.each do |transaction|
|
||||
begin
|
||||
info = transaction.fetch_enrichment_info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil?
|
||||
|
||||
transaction.entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def enrichable?
|
||||
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
|
||||
end
|
||||
|
||||
def enrichable_transactions
|
||||
transactions.active
|
||||
.includes(:merchant, :category)
|
||||
.where(
|
||||
"account_entries.enriched_at IS NULL",
|
||||
"OR merchant_id IS NULL",
|
||||
"OR category_id IS NULL"
|
||||
)
|
||||
end
|
||||
end
|
|
@ -65,10 +65,7 @@ class Account::Entry < ApplicationRecord
|
|||
bulk_attributes = {
|
||||
date: bulk_update_params[:date],
|
||||
notes: bulk_update_params[:notes],
|
||||
entryable_attributes: {
|
||||
category_id: bulk_update_params[:category_id],
|
||||
merchant_id: bulk_update_params[:merchant_id]
|
||||
}.compact_blank
|
||||
entryable_attributes: { category_id: bulk_update_params[:category_id] }.compact_blank
|
||||
}.compact_blank
|
||||
|
||||
return 0 if bulk_attributes.blank?
|
||||
|
|
|
@ -39,9 +39,9 @@ class Demo::Generator
|
|||
ActiveRecord::Base.transaction do
|
||||
create_tags!(family)
|
||||
create_categories!(family)
|
||||
create_merchants!(family)
|
||||
create_merchants!
|
||||
create_rules!(family)
|
||||
puts "tags, categories, merchants created for #{family_name}"
|
||||
puts "tags, categories, merchants created"
|
||||
|
||||
create_credit_card_account!(family)
|
||||
create_checking_account!(family)
|
||||
|
@ -213,13 +213,13 @@ class Demo::Generator
|
|||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!(family)
|
||||
def create_merchants!
|
||||
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
|
||||
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
|
||||
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
|
||||
|
||||
merchants.each do |merchant|
|
||||
family.merchants.create!(name: merchant, color: COLORS.sample)
|
||||
Merchant.find_or_create_by_normalized_name!(merchant)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ class Family < ApplicationRecord
|
|||
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
|
||||
has_many :budgets, dependent: :destroy
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
@ -36,6 +35,11 @@ class Family < ApplicationRecord
|
|||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||
|
||||
def merchants
|
||||
transaction_merchant_ids = self.transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
||||
Merchant.where(id: transaction_merchant_ids)
|
||||
end
|
||||
|
||||
def balance_sheet
|
||||
@balance_sheet ||= BalanceSheet.new(self)
|
||||
end
|
||||
|
|
|
@ -1,11 +1,24 @@
|
|||
class Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family }
|
||||
validates :name, presence: true, uniqueness: true
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
before_save :normalize_name
|
||||
|
||||
class << self
|
||||
def normalize_name(name)
|
||||
name.downcase.strip.titleize
|
||||
end
|
||||
|
||||
def find_or_create_by_normalized_name!(name)
|
||||
find_or_create_by!(name: normalize_name(name))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def normalize_name
|
||||
self.name = self.class.normalize_name(name)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -137,7 +137,7 @@ class PlaidAccount < ApplicationRecord
|
|||
def get_merchant(plaid_merchant_name)
|
||||
return nil if plaid_merchant_name.blank?
|
||||
|
||||
family.merchants.find_or_create_by!(name: plaid_merchant_name)
|
||||
Merchant.find_or_create_by!(name: plaid_merchant_name)
|
||||
end
|
||||
|
||||
def derive_plaid_cash_balance(plaid_balances)
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-subdued" } %>
|
||||
<%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-subdued" } %>
|
||||
<%= form.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5 %>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
@ -68,10 +68,7 @@
|
|||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
:id, :name,
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
class: "text-subdued" },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
{ label: "Merchant", class: "text-subdued" }, disabled: true %>
|
||||
|
||||
<%= ef.select :tag_ids,
|
||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<div data-controller="color-avatar">
|
||||
<%= styled_form_with model: @merchant, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<% Merchant::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||
<%= f.text_field :name, placeholder: t(".name_placeholder"), autofocus: true, required: true, data: { color_avatar_target: "name" } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<%= f.submit %>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
|
@ -1,33 +0,0 @@
|
|||
<%# locals: (merchant:) %>
|
||||
|
||||
<div class="flex justify-between items-center p-4 bg-white">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<% 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-primary text-sm truncate">
|
||||
<%= merchant.name %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-primary bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_merchant_path(merchant) %>
|
||||
|
||||
<%= contextual_menu_destructive_item t(".delete"),
|
||||
merchant_path(merchant),
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: merchant.transactions.any? ? {
|
||||
title: t(".confirm_title"),
|
||||
body: t(".confirm_body"),
|
||||
accept: t(".confirm_accept")
|
||||
} : nil %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
<div class="bg-white">
|
||||
<div class="h-px bg-alpha-black-50 ml-14 mr-6"></div>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
<%= modal_form_wrapper title: t(".title") do %>
|
||||
<%= render "form", merchant: @merchant %>
|
||||
<% end %>
|
|
@ -1,36 +0,0 @@
|
|||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium"><%= t(".title") %></h1>
|
||||
|
||||
<%= link_to new_merchant_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<div class="bg-white shadow-border-xs rounded-xl p-4">
|
||||
<% if @merchants.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<p><%= t(".title") %></p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= @merchants.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-border-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<%= render partial: @merchants, spacer_template: "merchants/ruler" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||
<p class="text-primary mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||
<%= link_to new_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
<%= modal_form_wrapper title: t(".title") do %>
|
||||
<%= render "form", merchant: @merchant %>
|
||||
<% end %>
|
|
@ -1,2 +1 @@
|
|||
<p>Placeholder: rules/_form partial</p>
|
||||
|
|
@ -1,2 +1 @@
|
|||
<p>Placeholder: rules#edit</p>
|
||||
|
|
@ -1,2 +1 @@
|
|||
<p>Placeholder: rules#new</p>
|
||||
|
|
@ -1,2 +1 @@
|
|||
<p>Placeholder: rules#show</p>
|
||||
|
|
@ -63,9 +63,6 @@
|
|||
<li>
|
||||
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: merchants_path, icon: "store" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
|
||||
<% end %>
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
|
||||
</div>
|
||||
<div class="my-2" id="list" data-list-filter-target="list">
|
||||
<% if Current.family.merchants.any? %>
|
||||
<% Current.family.merchants.alphabetically.each do |merchant| %>
|
||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
|
||||
<%= form.check_box :merchants,
|
||||
|
@ -16,10 +17,15 @@
|
|||
merchant.name,
|
||||
nil %>
|
||||
<%= form.label :merchants, value: merchant.name, class: "text-sm text-primary flex items-center gap-2" do %>
|
||||
<%= circle_logo(merchant.name, hex: merchant.color, size: "sm") %>
|
||||
<%= circle_logo(merchant.name, size: "sm") %>
|
||||
<%= merchant.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="px-3 py-2">
|
||||
<p class="text-sm text-secondary">No merchants associated with your transactions yet</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,8 +10,6 @@ en:
|
|||
category_placeholder: Select a category
|
||||
date_label: Date
|
||||
details: Details
|
||||
merchant_label: Merchant
|
||||
merchant_placeholder: Select a merchant
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note that will be applied to selected transactions
|
||||
overview: Overview
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
en:
|
||||
merchants:
|
||||
create:
|
||||
error: 'Error creating merchant: %{error}'
|
||||
success: New merchant created successfully
|
||||
destroy:
|
||||
success: Merchant deleted successfully
|
||||
edit:
|
||||
title: Edit merchant
|
||||
form:
|
||||
name_placeholder: Merchant name
|
||||
index:
|
||||
empty: No merchants yet
|
||||
new: New merchant
|
||||
title: Merchants
|
||||
merchant:
|
||||
confirm_accept: Delete merchant
|
||||
confirm_body: Are you sure you want to delete this merchant? Removing this merchant
|
||||
will unlink all associated transactions and may effect your reporting.
|
||||
confirm_title: Delete merchant?
|
||||
delete: Delete merchant
|
||||
edit: Edit merchant
|
||||
new:
|
||||
title: New merchant
|
||||
update:
|
||||
success: Merchant updated successfully
|
|
@ -34,7 +34,7 @@ en:
|
|||
is irreversible.
|
||||
title: Delete account?
|
||||
confirm_reset:
|
||||
body: Are you sure you want to reset your account? This will delete all your accounts, categories, merchants, tags, and other data. This action cannot be undone.
|
||||
body: Are you sure you want to reset your account? This will delete all your accounts, categories, tags, and other data. This action cannot be undone.
|
||||
title: Reset account?
|
||||
confirm_remove_invitation:
|
||||
body: Are you sure you want to remove the invitation for %{email}?
|
||||
|
@ -47,7 +47,7 @@ en:
|
|||
delete_account_warning: Deleting your account will permanently remove all
|
||||
your data and cannot be undone.
|
||||
reset_account: Reset account
|
||||
reset_account_warning: Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.
|
||||
reset_account_warning: Resetting your account will delete all your accounts, categories, tags, and other data, but keep your user account intact.
|
||||
email: Email
|
||||
first_name: First Name
|
||||
household_form_input_placeholder: Enter household name
|
||||
|
|
|
@ -4,7 +4,6 @@ en:
|
|||
header:
|
||||
edit_categories: Edit categories
|
||||
edit_imports: Edit imports
|
||||
edit_merchants: Edit merchants
|
||||
edit_tags: Edit tags
|
||||
import: Import
|
||||
index:
|
||||
|
|
|
@ -75,8 +75,6 @@ Rails.application.routes.draw do
|
|||
resources :budget_categories, only: %i[index show update]
|
||||
end
|
||||
|
||||
resources :merchants, only: %i[index new create edit update destroy]
|
||||
|
||||
resources :transfers, only: %i[new create destroy show update]
|
||||
|
||||
resources :imports, only: %i[index new show create destroy] do
|
||||
|
|
29
db/migrate/20250402195137_remove_merchant_management.rb
Normal file
29
db/migrate/20250402195137_remove_merchant_management.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
class RemoveMerchantManagement < ActiveRecord::Migration[7.2]
|
||||
# This migration removes "manual management" of merchants and moves us to 100% automated
|
||||
# detection of merchants based on transaction name (using Synth + AI).
|
||||
# -----
|
||||
# Once we're confident in changes, we'll come back and remove all "legacy" schemas.
|
||||
def change
|
||||
rename_table :merchants, :legacy_merchants
|
||||
rename_column :account_transactions, :merchant_id, :legacy_merchant_id
|
||||
|
||||
create_table :merchants, id: :uuid do |t|
|
||||
t.string :name, null: false, index: { unique: true }
|
||||
t.string :website_url
|
||||
t.string :icon_url
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_reference :account_transactions, :merchant, type: :uuid, foreign_key: true
|
||||
|
||||
# Users will now opt-in with "rules", so reset enriched names (original name retained)
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<-SQL
|
||||
UPDATE account_entries
|
||||
SET enriched_name = NULL
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
26
db/schema.rb
generated
26
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_04_02_195137) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -80,8 +80,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "category_id"
|
||||
t.uuid "legacy_merchant_id"
|
||||
t.uuid "merchant_id"
|
||||
t.index ["category_id"], name: "index_account_transactions_on_category_id"
|
||||
t.index ["legacy_merchant_id"], name: "index_account_transactions_on_legacy_merchant_id"
|
||||
t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
|
||||
end
|
||||
|
||||
|
@ -372,6 +374,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
|||
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "legacy_merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "color", default: "#e99537", null: false
|
||||
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_legacy_merchants_on_family_id"
|
||||
end
|
||||
|
||||
create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
@ -382,13 +395,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
|||
|
||||
create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "color", default: "#e99537", null: false
|
||||
t.uuid "family_id", null: false
|
||||
t.string "website_url"
|
||||
t.string "icon_url"
|
||||
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"
|
||||
t.index ["name"], name: "index_merchants_on_name", unique: true
|
||||
end
|
||||
|
||||
create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
@ -668,6 +679,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
|||
add_foreign_key "account_holdings", "securities"
|
||||
add_foreign_key "account_trades", "securities"
|
||||
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
||||
add_foreign_key "account_transactions", "legacy_merchants"
|
||||
add_foreign_key "account_transactions", "merchants"
|
||||
add_foreign_key "accounts", "families"
|
||||
add_foreign_key "accounts", "imports"
|
||||
|
@ -686,7 +698,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
|
|||
add_foreign_key "imports", "families"
|
||||
add_foreign_key "invitations", "families"
|
||||
add_foreign_key "invitations", "users", column: "inviter_id"
|
||||
add_foreign_key "merchants", "families"
|
||||
add_foreign_key "legacy_merchants", "families"
|
||||
add_foreign_key "messages", "chats"
|
||||
add_foreign_key "plaid_accounts", "plaid_items"
|
||||
add_foreign_key "plaid_items", "families"
|
||||
|
|
|
@ -49,8 +49,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
tag_ids: [ Tag.first.id, Tag.second.id ],
|
||||
category_id: Category.first.id,
|
||||
merchant_id: Merchant.first.id
|
||||
category_id: Category.first.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +63,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_equal -100, @entry.amount
|
||||
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
|
||||
assert_equal Category.first.id, @entry.entryable.category_id
|
||||
assert_equal Merchant.first.id, @entry.entryable.merchant_id
|
||||
assert_equal "test notes", @entry.notes
|
||||
assert_equal false, @entry.excluded
|
||||
|
||||
|
@ -98,7 +96,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
entry_ids: transactions.map(&:id),
|
||||
date: 1.day.ago.to_date,
|
||||
category_id: Category.second.id,
|
||||
merchant_id: Merchant.second.id,
|
||||
notes: "Updated note"
|
||||
}
|
||||
}
|
||||
|
@ -110,7 +107,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
transactions.reload.each do |transaction|
|
||||
assert_equal 1.day.ago.to_date, transaction.date
|
||||
assert_equal Category.second, transaction.account_transaction.category
|
||||
assert_equal Merchant.second, transaction.account_transaction.merchant
|
||||
assert_equal "Updated note", transaction.notes
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class MerchantsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@merchant = merchants(:netflix)
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get merchants_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new" do
|
||||
get new_merchant_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should create merchant" do
|
||||
assert_difference("Merchant.count") do
|
||||
post merchants_url, params: { merchant: { name: "new merchant", color: "#000000" } }
|
||||
end
|
||||
|
||||
assert_redirected_to merchants_path
|
||||
end
|
||||
|
||||
test "should update merchant" do
|
||||
patch merchant_url(@merchant), params: { merchant: { name: "new name", color: "#000000" } }
|
||||
assert_redirected_to merchants_path
|
||||
end
|
||||
|
||||
test "should destroy merchant" do
|
||||
assert_difference("Merchant.count", -1) do
|
||||
delete merchant_url(@merchant)
|
||||
end
|
||||
|
||||
assert_redirected_to merchants_path
|
||||
end
|
||||
end
|
|
@ -50,7 +50,6 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_not Account.exists?(account.id)
|
||||
assert_not Category.exists?(category.id)
|
||||
assert_not Tag.exists?(tag.id)
|
||||
assert_not Merchant.exists?(merchant.id)
|
||||
assert_not Import.exists?(import.id)
|
||||
assert_not Budget.exists?(budget.id)
|
||||
assert_not PlaidItem.exists?(plaid_item.id)
|
||||
|
|
8
test/fixtures/merchants.yml
vendored
8
test/fixtures/merchants.yml
vendored
|
@ -1,13 +1,5 @@
|
|||
one:
|
||||
name: Test
|
||||
family: empty
|
||||
|
||||
netflix:
|
||||
name: Netflix
|
||||
color: "#fd7f6f"
|
||||
family: dylan_family
|
||||
|
||||
amazon:
|
||||
name: Amazon
|
||||
color: "#fd7f6f"
|
||||
family: dylan_family
|
||||
|
|
|
@ -8,9 +8,6 @@ class Provider::SynthTest < ActiveSupport::TestCase
|
|||
@subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"])
|
||||
end
|
||||
|
||||
test "health check" do
|
||||
VCR.use_cassette("synth/health") do
|
||||
assert @synth.healthy?
|
||||
test "health check" do
|
||||
VCR.use_cassette("synth/health") do
|
||||
assert @synth.healthy?
|
||||
|
|
|
@ -8,14 +8,13 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
|||
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
|
||||
@grocery_category = @family.categories.create!(name: "Grocery")
|
||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
||||
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||
|
||||
# Some sample transactions to work with
|
||||
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant)
|
||||
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: merchants(:amazon))
|
||||
create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2")
|
||||
create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3")
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: @whole_foods_merchant)
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: merchants(:amazon))
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5")
|
||||
|
||||
@rule_scope = @account.transactions
|
||||
|
@ -25,13 +24,13 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
|||
action = Rule::Action.new(
|
||||
rule: @transaction_rule,
|
||||
action_type: "set_transaction_category",
|
||||
value: @grocery_category.id
|
||||
value: @shopping_category.id
|
||||
)
|
||||
|
||||
action.apply(@rule_scope)
|
||||
|
||||
@rule_scope.reload.each do |transaction|
|
||||
assert_equal @grocery_category.id, transaction.category_id
|
||||
assert_equal @shopping_category.id, transaction.category_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,14 +8,13 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
|||
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
|
||||
@grocery_category = @family.categories.create!(name: "Grocery")
|
||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
||||
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||
|
||||
# Some sample transactions to work with
|
||||
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant)
|
||||
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: merchants(:amazon))
|
||||
create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2")
|
||||
create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3")
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: @whole_foods_merchant)
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: merchants(:amazon))
|
||||
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5")
|
||||
|
||||
@rule_scope = @account.transactions
|
||||
|
@ -53,7 +52,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
|||
rule: @transaction_rule,
|
||||
condition_type: "transaction_merchant",
|
||||
operator: "=",
|
||||
value: "Whole Foods"
|
||||
value: "Amazon"
|
||||
)
|
||||
|
||||
filtered = condition.apply(@rule_scope)
|
||||
|
@ -69,7 +68,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
|||
Rule::Condition.new(
|
||||
condition_type: "transaction_merchant",
|
||||
operator: "=",
|
||||
value: "Whole Foods"
|
||||
value: "Amazon"
|
||||
),
|
||||
Rule::Condition.new(
|
||||
condition_type: "transaction_amount",
|
||||
|
@ -92,7 +91,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
|||
Rule::Condition.new(
|
||||
condition_type: "transaction_merchant",
|
||||
operator: "=",
|
||||
value: "Whole Foods"
|
||||
value: "Amazon"
|
||||
),
|
||||
Rule::Condition.new(
|
||||
condition_type: "transaction_amount",
|
||||
|
|
|
@ -6,31 +6,30 @@ class RuleTest < ActiveSupport::TestCase
|
|||
setup do
|
||||
@family = families(:empty)
|
||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
||||
@groceries_category = @family.categories.create!(name: "Groceries")
|
||||
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||
end
|
||||
|
||||
test "basic rule" do
|
||||
transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant)
|
||||
transaction_entry = create_transaction(date: Date.current, account: @account, merchant: merchants(:amazon))
|
||||
|
||||
rule = Rule.create!(
|
||||
family: @family,
|
||||
resource_type: "transaction",
|
||||
effective_date: 1.day.ago.to_date,
|
||||
conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") ],
|
||||
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") ]
|
||||
conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Amazon") ],
|
||||
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @shopping_category.id) ]
|
||||
)
|
||||
|
||||
rule.apply
|
||||
|
||||
transaction_entry.reload
|
||||
|
||||
assert_equal @groceries_category, transaction_entry.account_transaction.category
|
||||
assert_equal @shopping_category, transaction_entry.account_transaction.category
|
||||
end
|
||||
|
||||
test "compound rule" do
|
||||
transaction_entry1 = create_transaction(date: Date.current, amount: 50, account: @account, merchant: @whole_foods_merchant)
|
||||
transaction_entry2 = create_transaction(date: Date.current, amount: 100, account: @account, merchant: @whole_foods_merchant)
|
||||
transaction_entry1 = create_transaction(date: Date.current, amount: 50, account: @account, merchant: merchants(:amazon))
|
||||
transaction_entry2 = create_transaction(date: Date.current, amount: 100, account: @account, merchant: merchants(:amazon))
|
||||
|
||||
# Assign "Groceries" to transactions with a merchant of "Whole Foods" and an amount greater than $60
|
||||
rule = Rule.create!(
|
||||
|
@ -39,11 +38,11 @@ class RuleTest < ActiveSupport::TestCase
|
|||
effective_date: 1.day.ago.to_date,
|
||||
conditions: [
|
||||
Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [
|
||||
Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods"),
|
||||
Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Amazon"),
|
||||
Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60)
|
||||
])
|
||||
],
|
||||
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") ]
|
||||
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @shopping_category.id) ]
|
||||
)
|
||||
|
||||
rule.apply
|
||||
|
@ -52,6 +51,6 @@ class RuleTest < ActiveSupport::TestCase
|
|||
transaction_entry2.reload
|
||||
|
||||
assert_nil transaction_entry1.account_transaction.category
|
||||
assert_equal @groceries_category, transaction_entry2.account_transaction.category
|
||||
assert_equal @shopping_category, transaction_entry2.account_transaction.category
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,7 +63,7 @@ class TransactionImportTest < ActiveSupport::TestCase
|
|||
-> { Tag.count } => 1,
|
||||
-> { Category.count } => 1,
|
||||
-> { Account.count } => 1 do
|
||||
@import.publish
|
||||
result = @import.publish
|
||||
end
|
||||
|
||||
assert_equal "complete", @import.status
|
||||
|
|
|
@ -10,7 +10,6 @@ class SettingsTest < ApplicationSystemTestCase
|
|||
[ "Accounts", accounts_path ],
|
||||
[ "Tags", tags_path ],
|
||||
[ "Categories", categories_path ],
|
||||
[ "Merchants", merchants_path ],
|
||||
[ "Imports", imports_path ],
|
||||
[ "What's new", changelog_path ],
|
||||
[ "Feedback", feedback_path ]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue