mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29: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
|
class Account::TransactionsController < ApplicationController
|
||||||
include EntryableResource
|
include EntryableResource
|
||||||
|
|
||||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
permitted_entryable_attributes :id, :category_id, { tag_ids: [] }
|
||||||
|
|
||||||
def bulk_delete
|
def bulk_delete
|
||||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||||
|
@ -27,11 +27,11 @@ class Account::TransactionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_update_params
|
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
|
end
|
||||||
|
|
||||||
def search_params
|
def search_params
|
||||||
params.fetch(:q, {})
|
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
|
||||||
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.tags_label"), path: :tags_path },
|
||||||
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
|
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
|
||||||
{ name: "Rules", path: :rules_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.whats_new_label"), path: :changelog_path },
|
||||||
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
|
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,7 +8,6 @@ class FamilyResetJob < ApplicationJob
|
||||||
family.accounts.destroy_all
|
family.accounts.destroy_all
|
||||||
family.categories.destroy_all
|
family.categories.destroy_all
|
||||||
family.tags.destroy_all
|
family.tags.destroy_all
|
||||||
family.merchants.destroy_all
|
|
||||||
family.plaid_items.destroy_all
|
family.plaid_items.destroy_all
|
||||||
family.imports.destroy_all
|
family.imports.destroy_all
|
||||||
family.budgets.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 = {
|
bulk_attributes = {
|
||||||
date: bulk_update_params[:date],
|
date: bulk_update_params[:date],
|
||||||
notes: bulk_update_params[:notes],
|
notes: bulk_update_params[:notes],
|
||||||
entryable_attributes: {
|
entryable_attributes: { category_id: bulk_update_params[:category_id] }.compact_blank
|
||||||
category_id: bulk_update_params[:category_id],
|
|
||||||
merchant_id: bulk_update_params[:merchant_id]
|
|
||||||
}.compact_blank
|
|
||||||
}.compact_blank
|
}.compact_blank
|
||||||
|
|
||||||
return 0 if bulk_attributes.blank?
|
return 0 if bulk_attributes.blank?
|
||||||
|
|
|
@ -39,9 +39,9 @@ class Demo::Generator
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
create_tags!(family)
|
create_tags!(family)
|
||||||
create_categories!(family)
|
create_categories!(family)
|
||||||
create_merchants!(family)
|
create_merchants!
|
||||||
create_rules!(family)
|
create_rules!(family)
|
||||||
puts "tags, categories, merchants created for #{family_name}"
|
puts "tags, categories, merchants created"
|
||||||
|
|
||||||
create_credit_card_account!(family)
|
create_credit_card_account!(family)
|
||||||
create_checking_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")
|
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense")
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_merchants!(family)
|
def create_merchants!
|
||||||
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
|
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
|
||||||
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
|
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
|
||||||
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
|
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
|
||||||
|
|
||||||
merchants.each do |merchant|
|
merchants.each do |merchant|
|
||||||
family.merchants.create!(name: merchant, color: COLORS.sample)
|
Merchant.find_or_create_by_normalized_name!(merchant)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,6 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
has_many :tags, dependent: :destroy
|
has_many :tags, dependent: :destroy
|
||||||
has_many :categories, dependent: :destroy
|
has_many :categories, dependent: :destroy
|
||||||
has_many :merchants, dependent: :destroy
|
|
||||||
|
|
||||||
has_many :budgets, dependent: :destroy
|
has_many :budgets, dependent: :destroy
|
||||||
has_many :budget_categories, through: :budgets
|
has_many :budget_categories, through: :budgets
|
||||||
|
@ -36,6 +35,11 @@ class Family < ApplicationRecord
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
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
|
def balance_sheet
|
||||||
@balance_sheet ||= BalanceSheet.new(self)
|
@balance_sheet ||= BalanceSheet.new(self)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,11 +1,24 @@
|
||||||
class Merchant < ApplicationRecord
|
class Merchant < ApplicationRecord
|
||||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||||
belongs_to :family
|
|
||||||
|
|
||||||
validates :name, :color, :family, presence: true
|
validates :name, presence: true, uniqueness: true
|
||||||
validates :name, uniqueness: { scope: :family }
|
|
||||||
|
|
||||||
scope :alphabetically, -> { order(:name) }
|
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
|
end
|
||||||
|
|
|
@ -137,7 +137,7 @@ class PlaidAccount < ApplicationRecord
|
||||||
def get_merchant(plaid_merchant_name)
|
def get_merchant(plaid_merchant_name)
|
||||||
return nil if plaid_merchant_name.blank?
|
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
|
end
|
||||||
|
|
||||||
def derive_plaid_cash_balance(plaid_balances)
|
def derive_plaid_cash_balance(plaid_balances)
|
||||||
|
|
|
@ -39,7 +39,6 @@
|
||||||
|
|
||||||
<div class="space-y-2">
|
<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 :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 %>
|
<%= form.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5 %>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
@ -68,10 +68,7 @@
|
||||||
<%= ef.collection_select :merchant_id,
|
<%= ef.collection_select :merchant_id,
|
||||||
Current.family.merchants.alphabetically,
|
Current.family.merchants.alphabetically,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ include_blank: t(".none"),
|
{ label: "Merchant", class: "text-subdued" }, disabled: true %>
|
||||||
label: t(".merchant_label"),
|
|
||||||
class: "text-subdued" },
|
|
||||||
"data-auto-submit-form-target": "auto" %>
|
|
||||||
|
|
||||||
<%= ef.select :tag_ids,
|
<%= ef.select :tag_ids,
|
||||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
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>
|
<p>Placeholder: rules/_form partial</p>
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
<p>Placeholder: rules#edit</p>
|
<p>Placeholder: rules#edit</p>
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
<p>Placeholder: rules#new</p>
|
<p>Placeholder: rules#new</p>
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
<p>Placeholder: rules#show</p>
|
<p>Placeholder: rules#show</p>
|
||||||
|
|
|
@ -63,9 +63,6 @@
|
||||||
<li>
|
<li>
|
||||||
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: merchants_path, icon: "store" %>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
<%= contextual_menu do %>
|
<%= 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_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_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 %>
|
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
|
||||||
<% end %>
|
<% 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") %>
|
<%= 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>
|
||||||
<div class="my-2" id="list" data-list-filter-target="list">
|
<div class="my-2" id="list" data-list-filter-target="list">
|
||||||
|
<% if Current.family.merchants.any? %>
|
||||||
<% Current.family.merchants.alphabetically.each do |merchant| %>
|
<% Current.family.merchants.alphabetically.each do |merchant| %>
|
||||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
|
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
|
||||||
<%= form.check_box :merchants,
|
<%= form.check_box :merchants,
|
||||||
|
@ -16,10 +17,15 @@
|
||||||
merchant.name,
|
merchant.name,
|
||||||
nil %>
|
nil %>
|
||||||
<%= form.label :merchants, value: merchant.name, class: "text-sm text-primary flex items-center gap-2" do %>
|
<%= 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 %>
|
<%= merchant.name %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,8 +10,6 @@ en:
|
||||||
category_placeholder: Select a category
|
category_placeholder: Select a category
|
||||||
date_label: Date
|
date_label: Date
|
||||||
details: Details
|
details: Details
|
||||||
merchant_label: Merchant
|
|
||||||
merchant_placeholder: Select a merchant
|
|
||||||
note_label: Notes
|
note_label: Notes
|
||||||
note_placeholder: Enter a note that will be applied to selected transactions
|
note_placeholder: Enter a note that will be applied to selected transactions
|
||||||
overview: Overview
|
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.
|
is irreversible.
|
||||||
title: Delete account?
|
title: Delete account?
|
||||||
confirm_reset:
|
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?
|
title: Reset account?
|
||||||
confirm_remove_invitation:
|
confirm_remove_invitation:
|
||||||
body: Are you sure you want to remove the invitation for %{email}?
|
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
|
delete_account_warning: Deleting your account will permanently remove all
|
||||||
your data and cannot be undone.
|
your data and cannot be undone.
|
||||||
reset_account: Reset account
|
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
|
email: Email
|
||||||
first_name: First Name
|
first_name: First Name
|
||||||
household_form_input_placeholder: Enter household name
|
household_form_input_placeholder: Enter household name
|
||||||
|
|
|
@ -4,7 +4,6 @@ en:
|
||||||
header:
|
header:
|
||||||
edit_categories: Edit categories
|
edit_categories: Edit categories
|
||||||
edit_imports: Edit imports
|
edit_imports: Edit imports
|
||||||
edit_merchants: Edit merchants
|
|
||||||
edit_tags: Edit tags
|
edit_tags: Edit tags
|
||||||
import: Import
|
import: Import
|
||||||
index:
|
index:
|
||||||
|
|
|
@ -75,8 +75,6 @@ Rails.application.routes.draw do
|
||||||
resources :budget_categories, only: %i[index show update]
|
resources :budget_categories, only: %i[index show update]
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :merchants, only: %i[index new create edit update destroy]
|
|
||||||
|
|
||||||
resources :transfers, only: %i[new create destroy show update]
|
resources :transfers, only: %i[new create destroy show update]
|
||||||
|
|
||||||
resources :imports, only: %i[index new show create destroy] do
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.uuid "category_id"
|
t.uuid "category_id"
|
||||||
|
t.uuid "legacy_merchant_id"
|
||||||
t.uuid "merchant_id"
|
t.uuid "merchant_id"
|
||||||
t.index ["category_id"], name: "index_account_transactions_on_category_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"
|
t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
|
||||||
end
|
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
|
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||||
end
|
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|
|
create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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|
|
create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "color", default: "#e99537", null: false
|
t.string "website_url"
|
||||||
t.uuid "family_id", null: false
|
t.string "icon_url"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "icon_url"
|
t.index ["name"], name: "index_merchants_on_name", unique: true
|
||||||
t.datetime "enriched_at"
|
|
||||||
t.index ["family_id"], name: "index_merchants_on_family_id"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
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_holdings", "securities"
|
||||||
add_foreign_key "account_trades", "securities"
|
add_foreign_key "account_trades", "securities"
|
||||||
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
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 "account_transactions", "merchants"
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
add_foreign_key "accounts", "imports"
|
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 "imports", "families"
|
||||||
add_foreign_key "invitations", "families"
|
add_foreign_key "invitations", "families"
|
||||||
add_foreign_key "invitations", "users", column: "inviter_id"
|
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 "messages", "chats"
|
||||||
add_foreign_key "plaid_accounts", "plaid_items"
|
add_foreign_key "plaid_accounts", "plaid_items"
|
||||||
add_foreign_key "plaid_items", "families"
|
add_foreign_key "plaid_items", "families"
|
||||||
|
|
|
@ -49,8 +49,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
entryable_attributes: {
|
entryable_attributes: {
|
||||||
id: @entry.entryable_id,
|
id: @entry.entryable_id,
|
||||||
tag_ids: [ Tag.first.id, Tag.second.id ],
|
tag_ids: [ Tag.first.id, Tag.second.id ],
|
||||||
category_id: Category.first.id,
|
category_id: Category.first.id
|
||||||
merchant_id: Merchant.first.id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,7 +63,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_equal -100, @entry.amount
|
assert_equal -100, @entry.amount
|
||||||
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
|
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
|
||||||
assert_equal Category.first.id, @entry.entryable.category_id
|
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 "test notes", @entry.notes
|
||||||
assert_equal false, @entry.excluded
|
assert_equal false, @entry.excluded
|
||||||
|
|
||||||
|
@ -98,7 +96,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
entry_ids: transactions.map(&:id),
|
entry_ids: transactions.map(&:id),
|
||||||
date: 1.day.ago.to_date,
|
date: 1.day.ago.to_date,
|
||||||
category_id: Category.second.id,
|
category_id: Category.second.id,
|
||||||
merchant_id: Merchant.second.id,
|
|
||||||
notes: "Updated note"
|
notes: "Updated note"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,7 +107,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
transactions.reload.each do |transaction|
|
transactions.reload.each do |transaction|
|
||||||
assert_equal 1.day.ago.to_date, transaction.date
|
assert_equal 1.day.ago.to_date, transaction.date
|
||||||
assert_equal Category.second, transaction.account_transaction.category
|
assert_equal Category.second, transaction.account_transaction.category
|
||||||
assert_equal Merchant.second, transaction.account_transaction.merchant
|
|
||||||
assert_equal "Updated note", transaction.notes
|
assert_equal "Updated note", transaction.notes
|
||||||
end
|
end
|
||||||
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 Account.exists?(account.id)
|
||||||
assert_not Category.exists?(category.id)
|
assert_not Category.exists?(category.id)
|
||||||
assert_not Tag.exists?(tag.id)
|
assert_not Tag.exists?(tag.id)
|
||||||
assert_not Merchant.exists?(merchant.id)
|
|
||||||
assert_not Import.exists?(import.id)
|
assert_not Import.exists?(import.id)
|
||||||
assert_not Budget.exists?(budget.id)
|
assert_not Budget.exists?(budget.id)
|
||||||
assert_not PlaidItem.exists?(plaid_item.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:
|
netflix:
|
||||||
name: Netflix
|
name: Netflix
|
||||||
color: "#fd7f6f"
|
|
||||||
family: dylan_family
|
|
||||||
|
|
||||||
amazon:
|
amazon:
|
||||||
name: 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"])
|
@subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"])
|
||||||
end
|
end
|
||||||
|
|
||||||
test "health check" do
|
|
||||||
VCR.use_cassette("synth/health") do
|
|
||||||
assert @synth.healthy?
|
|
||||||
test "health check" do
|
test "health check" do
|
||||||
VCR.use_cassette("synth/health") do
|
VCR.use_cassette("synth/health") do
|
||||||
assert @synth.healthy?
|
assert @synth.healthy?
|
||||||
|
|
|
@ -8,14 +8,13 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
||||||
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
||||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||||
|
|
||||||
@grocery_category = @family.categories.create!(name: "Grocery")
|
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
|
||||||
|
|
||||||
# Some sample transactions to work with
|
# 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: 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.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")
|
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5")
|
||||||
|
|
||||||
@rule_scope = @account.transactions
|
@rule_scope = @account.transactions
|
||||||
|
@ -25,13 +24,13 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
||||||
action = Rule::Action.new(
|
action = Rule::Action.new(
|
||||||
rule: @transaction_rule,
|
rule: @transaction_rule,
|
||||||
action_type: "set_transaction_category",
|
action_type: "set_transaction_category",
|
||||||
value: @grocery_category.id
|
value: @shopping_category.id
|
||||||
)
|
)
|
||||||
|
|
||||||
action.apply(@rule_scope)
|
action.apply(@rule_scope)
|
||||||
|
|
||||||
@rule_scope.reload.each do |transaction|
|
@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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,14 +8,13 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
||||||
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
@transaction_rule = @family.rules.create!(resource_type: "transaction")
|
||||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||||
|
|
||||||
@grocery_category = @family.categories.create!(name: "Grocery")
|
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
|
||||||
|
|
||||||
# Some sample transactions to work with
|
# 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: 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.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")
|
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5")
|
||||||
|
|
||||||
@rule_scope = @account.transactions
|
@rule_scope = @account.transactions
|
||||||
|
@ -53,7 +52,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
||||||
rule: @transaction_rule,
|
rule: @transaction_rule,
|
||||||
condition_type: "transaction_merchant",
|
condition_type: "transaction_merchant",
|
||||||
operator: "=",
|
operator: "=",
|
||||||
value: "Whole Foods"
|
value: "Amazon"
|
||||||
)
|
)
|
||||||
|
|
||||||
filtered = condition.apply(@rule_scope)
|
filtered = condition.apply(@rule_scope)
|
||||||
|
@ -69,7 +68,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
||||||
Rule::Condition.new(
|
Rule::Condition.new(
|
||||||
condition_type: "transaction_merchant",
|
condition_type: "transaction_merchant",
|
||||||
operator: "=",
|
operator: "=",
|
||||||
value: "Whole Foods"
|
value: "Amazon"
|
||||||
),
|
),
|
||||||
Rule::Condition.new(
|
Rule::Condition.new(
|
||||||
condition_type: "transaction_amount",
|
condition_type: "transaction_amount",
|
||||||
|
@ -92,7 +91,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
||||||
Rule::Condition.new(
|
Rule::Condition.new(
|
||||||
condition_type: "transaction_merchant",
|
condition_type: "transaction_merchant",
|
||||||
operator: "=",
|
operator: "=",
|
||||||
value: "Whole Foods"
|
value: "Amazon"
|
||||||
),
|
),
|
||||||
Rule::Condition.new(
|
Rule::Condition.new(
|
||||||
condition_type: "transaction_amount",
|
condition_type: "transaction_amount",
|
||||||
|
|
|
@ -6,31 +6,30 @@ class RuleTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@family = families(:empty)
|
@family = families(:empty)
|
||||||
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||||
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
|
@shopping_category = @family.categories.create!(name: "Shopping")
|
||||||
@groceries_category = @family.categories.create!(name: "Groceries")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "basic rule" do
|
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!(
|
rule = Rule.create!(
|
||||||
family: @family,
|
family: @family,
|
||||||
resource_type: "transaction",
|
resource_type: "transaction",
|
||||||
effective_date: 1.day.ago.to_date,
|
effective_date: 1.day.ago.to_date,
|
||||||
conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") ],
|
conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Amazon") ],
|
||||||
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
|
rule.apply
|
||||||
|
|
||||||
transaction_entry.reload
|
transaction_entry.reload
|
||||||
|
|
||||||
assert_equal @groceries_category, transaction_entry.account_transaction.category
|
assert_equal @shopping_category, transaction_entry.account_transaction.category
|
||||||
end
|
end
|
||||||
|
|
||||||
test "compound rule" do
|
test "compound rule" do
|
||||||
transaction_entry1 = create_transaction(date: Date.current, amount: 50, 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: @whole_foods_merchant)
|
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
|
# Assign "Groceries" to transactions with a merchant of "Whole Foods" and an amount greater than $60
|
||||||
rule = Rule.create!(
|
rule = Rule.create!(
|
||||||
|
@ -39,11 +38,11 @@ class RuleTest < ActiveSupport::TestCase
|
||||||
effective_date: 1.day.ago.to_date,
|
effective_date: 1.day.ago.to_date,
|
||||||
conditions: [
|
conditions: [
|
||||||
Rule::Condition.new(condition_type: "compound", operator: "and", sub_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)
|
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
|
rule.apply
|
||||||
|
@ -52,6 +51,6 @@ class RuleTest < ActiveSupport::TestCase
|
||||||
transaction_entry2.reload
|
transaction_entry2.reload
|
||||||
|
|
||||||
assert_nil transaction_entry1.account_transaction.category
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -63,7 +63,7 @@ class TransactionImportTest < ActiveSupport::TestCase
|
||||||
-> { Tag.count } => 1,
|
-> { Tag.count } => 1,
|
||||||
-> { Category.count } => 1,
|
-> { Category.count } => 1,
|
||||||
-> { Account.count } => 1 do
|
-> { Account.count } => 1 do
|
||||||
@import.publish
|
result = @import.publish
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "complete", @import.status
|
assert_equal "complete", @import.status
|
||||||
|
|
|
@ -10,7 +10,6 @@ class SettingsTest < ApplicationSystemTestCase
|
||||||
[ "Accounts", accounts_path ],
|
[ "Accounts", accounts_path ],
|
||||||
[ "Tags", tags_path ],
|
[ "Tags", tags_path ],
|
||||||
[ "Categories", categories_path ],
|
[ "Categories", categories_path ],
|
||||||
[ "Merchants", merchants_path ],
|
|
||||||
[ "Imports", imports_path ],
|
[ "Imports", imports_path ],
|
||||||
[ "What's new", changelog_path ],
|
[ "What's new", changelog_path ],
|
||||||
[ "Feedback", feedback_path ]
|
[ "Feedback", feedback_path ]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue