mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Plaid category matching
This commit is contained in:
parent
d60d57f10f
commit
69159fdc86
47 changed files with 917 additions and 109 deletions
|
@ -56,8 +56,13 @@ class CategoriesController < ApplicationController
|
|||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy_all
|
||||
Current.family.categories.destroy_all
|
||||
redirect_back_or_to categories_path, notice: "All categories deleted"
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
Current.family.categories.bootstrap!
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
|
|
@ -48,7 +48,7 @@ class TradesController < ApplicationController
|
|||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: [ :id, :qty, :price ]
|
||||
)
|
||||
end
|
||||
|
|
|
@ -118,7 +118,7 @@ class TransactionsController < ApplicationController
|
|||
|
||||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
|
|
|
@ -44,6 +44,6 @@ class ValuationsController < ApplicationController
|
|||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:name, :enriched_name, :date, :amount, :currency, :notes)
|
||||
.permit(:name, :date, :amount, :currency, :notes)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -35,9 +35,9 @@ module ApplicationHelper
|
|||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def modal(reload_on_close: false, &block)
|
||||
def modal(reload_on_close: false, overflow_visible: false, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/modal", locals: { content:, reload_on_close: }
|
||||
render partial: "shared/modal", locals: { content:, reload_on_close:, overflow_visible: }
|
||||
end
|
||||
|
||||
##
|
||||
|
|
|
@ -34,7 +34,7 @@ module EntriesHelper
|
|||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
entry.name
|
||||
].join(" • ")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,10 +4,10 @@ module FormsHelper
|
|||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def modal_form_wrapper(title:, subtitle: nil, &block)
|
||||
def modal_form_wrapper(title:, subtitle: nil, overflow_visible: false, &block)
|
||||
content = capture &block
|
||||
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content:, overflow_visible: }
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
|
|
|
@ -50,11 +50,11 @@ class Category < ApplicationRecord
|
|||
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
|
||||
end
|
||||
|
||||
def bootstrap_defaults
|
||||
default_categories.each do |name, color, icon|
|
||||
def bootstrap!
|
||||
default_categories.each do |name, color, icon, classification|
|
||||
find_or_create_by!(name: name) do |category|
|
||||
category.color = color
|
||||
category.classification = "income" if name == "Income"
|
||||
category.classification = classification
|
||||
category.lucide_icon = icon
|
||||
end
|
||||
end
|
||||
|
@ -71,18 +71,20 @@ class Category < ApplicationRecord
|
|||
private
|
||||
def default_categories
|
||||
[
|
||||
[ "Income", "#e99537", "circle-dollar-sign" ],
|
||||
[ "Housing", "#6471eb", "house" ],
|
||||
[ "Entertainment", "#df4e92", "drama" ],
|
||||
[ "Food & Drink", "#eb5429", "utensils" ],
|
||||
[ "Shopping", "#e99537", "shopping-cart" ],
|
||||
[ "Healthcare", "#4da568", "pill" ],
|
||||
[ "Insurance", "#6471eb", "piggy-bank" ],
|
||||
[ "Utilities", "#db5a54", "lightbulb" ],
|
||||
[ "Transportation", "#df4e92", "bus" ],
|
||||
[ "Education", "#eb5429", "book" ],
|
||||
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
|
||||
[ "Subscriptions", "#805dee", "credit-card" ]
|
||||
[ "Income", "#e99537", "circle-dollar-sign", "income" ],
|
||||
[ "Loan Payments", "#6471eb", "credit-card", "expense" ],
|
||||
[ "Fees", "#6471eb", "credit-card", "expense" ],
|
||||
[ "Entertainment", "#df4e92", "drama", "expense" ],
|
||||
[ "Food & Drink", "#eb5429", "utensils", "expense" ],
|
||||
[ "Shopping", "#e99537", "shopping-cart", "expense" ],
|
||||
[ "Home Improvement", "#6471eb", "house", "expense" ],
|
||||
[ "Healthcare", "#4da568", "pill", "expense" ],
|
||||
[ "Personal Care", "#4da568", "pill", "expense" ],
|
||||
[ "Services", "#4da568", "briefcase", "expense" ],
|
||||
[ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ],
|
||||
[ "Transportation", "#df4e92", "bus", "expense" ],
|
||||
[ "Travel", "#df4e92", "plane", "expense" ],
|
||||
[ "Rent & Utilities", "#db5a54", "lightbulb", "expense" ]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -205,7 +205,7 @@ class Demo::Generator
|
|||
end
|
||||
|
||||
def create_categories!(family)
|
||||
family.categories.bootstrap_defaults
|
||||
family.categories.bootstrap!
|
||||
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense")
|
||||
|
|
|
@ -56,10 +56,6 @@ class Entry < ApplicationRecord
|
|||
Balance::TrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def display_name
|
||||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
EntrySearch.new(params).build_query(all)
|
||||
|
|
|
@ -16,7 +16,7 @@ class EntrySearch
|
|||
return scope if search.blank?
|
||||
|
||||
query = scope
|
||||
query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search",
|
||||
query = query.where("entries.name ILIKE :search",
|
||||
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||
)
|
||||
query
|
||||
|
|
|
@ -167,4 +167,8 @@ class Family < ApplicationRecord
|
|||
entries.maximum(:updated_at)
|
||||
].compact.join("_")
|
||||
end
|
||||
|
||||
def self_hoster?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -74,7 +74,8 @@ class Family::AutoCategorizer
|
|||
amount: transaction.entry.amount.abs,
|
||||
classification: transaction.entry.classification,
|
||||
description: transaction.entry.name,
|
||||
merchant: transaction.merchant&.name
|
||||
merchant: transaction.merchant&.name,
|
||||
hint: transaction.plaid_category_detailed
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -83,13 +83,14 @@ class PlaidAccount < ApplicationRecord
|
|||
def sync_transactions!(added:, modified:, removed:)
|
||||
added.each do |plaid_txn|
|
||||
account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t|
|
||||
t.name = plaid_txn.name
|
||||
t.name = plaid_txn.merchant_name || plaid_txn.original_description
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.entryable = Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn)
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -99,7 +100,12 @@ class PlaidAccount < ApplicationRecord
|
|||
|
||||
existing_txn.update!(
|
||||
amount: plaid_txn.amount,
|
||||
date: plaid_txn.date
|
||||
date: plaid_txn.date,
|
||||
entryable_attributes: {
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -125,26 +131,17 @@ class PlaidAccount < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
def get_category(plaid_category)
|
||||
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
|
||||
|
||||
return nil if ignored_categories.include?(plaid_category)
|
||||
|
||||
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
||||
end
|
||||
|
||||
def get_merchant(plaid_txn)
|
||||
def find_or_create_merchant(plaid_txn)
|
||||
unless plaid_txn.merchant_entity_id.present? && plaid_txn.merchant_name.present?
|
||||
return nil
|
||||
end
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
source: "plaid",
|
||||
provider_merchant_id: plaid_txn.merchant_entity_id,
|
||||
website_url: plaid_txn.website
|
||||
name: plaid_txn.merchant_name,
|
||||
) do |m|
|
||||
m.name = plaid_txn.merchant_name
|
||||
m.provider_merchant_id = plaid_txn.merchant_entity_id
|
||||
m.website_url = plaid_txn.website
|
||||
m.logo_url = plaid_txn.logo_url
|
||||
end
|
||||
end
|
||||
|
|
|
@ -80,6 +80,7 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
|
||||
def post_sync(sync)
|
||||
auto_match_categories!
|
||||
family.broadcast_refresh
|
||||
end
|
||||
|
||||
|
@ -88,6 +89,36 @@ class PlaidItem < ApplicationRecord
|
|||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def auto_match_categories!
|
||||
if family.categories.none?
|
||||
family.categories.bootstrap!
|
||||
end
|
||||
|
||||
alias_matcher = build_category_alias_matcher(family.categories)
|
||||
|
||||
accounts.each do |account|
|
||||
matchable_transactions = account.transactions
|
||||
.where(category_id: nil)
|
||||
.where.not(plaid_category: nil)
|
||||
.enrichable(:category_id)
|
||||
|
||||
matchable_transactions.each do |transaction|
|
||||
category = alias_matcher.match(transaction.plaid_category_detailed)
|
||||
|
||||
if category.present?
|
||||
PlaidItem.transaction do
|
||||
transaction.log_enrichment!(
|
||||
attribute_name: "category_id",
|
||||
attribute_value: category.id,
|
||||
source: "plaid"
|
||||
)
|
||||
transaction.set_category!(category)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_and_load_plaid_data
|
||||
data = {}
|
||||
|
|
|
@ -15,6 +15,10 @@ module PlaidItem::Provided
|
|||
end
|
||||
end
|
||||
|
||||
def build_category_alias_matcher(user_categories)
|
||||
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
||||
end
|
||||
|
||||
private
|
||||
def eu?
|
||||
raise "eu? is not implemented for #{self.class.name}"
|
||||
|
|
|
@ -121,7 +121,10 @@ class Provider::Plaid
|
|||
while has_more
|
||||
request = Plaid::TransactionsSyncRequest.new(
|
||||
access_token: item.access_token,
|
||||
cursor: cursor
|
||||
cursor: cursor,
|
||||
options: {
|
||||
include_original_description: true
|
||||
}
|
||||
)
|
||||
|
||||
response = client.transactions_sync(request)
|
||||
|
|
109
app/models/provider/plaid/category_alias_matcher.rb
Normal file
109
app/models/provider/plaid/category_alias_matcher.rb
Normal file
|
@ -0,0 +1,109 @@
|
|||
# The purpose of this matcher is to auto-match Plaid categories to
|
||||
# known internal user categories. Since we allow users to define their own
|
||||
# categories we cannot directly assign Plaid categories as this would overwrite
|
||||
# user data and create a confusing experience.
|
||||
#
|
||||
# Automated category matching in the Maybe app has a hierarchy:
|
||||
# 1. Naive string matching via CategoryAliasMatcher
|
||||
# 2. Rules-based matching set by user
|
||||
# 3. AI-powered matching (also enabled by user via rules)
|
||||
#
|
||||
# This class is simply a FAST and CHEAP way to match categories that are high confidence.
|
||||
# Edge cases will be handled by user-defined rules.
|
||||
class Provider::Plaid::CategoryAliasMatcher
|
||||
include Provider::Plaid::CategoryTaxonomy
|
||||
|
||||
def initialize(user_categories)
|
||||
@user_categories = user_categories
|
||||
end
|
||||
|
||||
def match(plaid_detailed_category)
|
||||
plaid_category_details = get_plaid_category_details(plaid_detailed_category)
|
||||
return nil unless plaid_category_details
|
||||
|
||||
# Try exact name matches first
|
||||
exact_match = normalized_user_categories.find do |category|
|
||||
category[:name] == plaid_category_details[:key].to_s
|
||||
end
|
||||
return user_categories.find { |c| c.id == exact_match[:id] } if exact_match
|
||||
|
||||
# Try detailed aliases matches with fuzzy matching
|
||||
alias_match = normalized_user_categories.find do |category|
|
||||
name = category[:name]
|
||||
plaid_category_details[:aliases].any? do |a|
|
||||
alias_str = a.to_s
|
||||
|
||||
# Try exact match
|
||||
next true if name == alias_str
|
||||
|
||||
# Try plural forms
|
||||
next true if name.singularize == alias_str || name.pluralize == alias_str
|
||||
next true if alias_str.singularize == name || alias_str.pluralize == name
|
||||
|
||||
# Try common forms
|
||||
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_name == normalized_alias
|
||||
end
|
||||
end
|
||||
return user_categories.find { |c| c.id == alias_match[:id] } if alias_match
|
||||
|
||||
# Try parent aliases matches with fuzzy matching
|
||||
parent_match = normalized_user_categories.find do |category|
|
||||
name = category[:name]
|
||||
plaid_category_details[:parent_aliases].any? do |a|
|
||||
alias_str = a.to_s
|
||||
|
||||
# Try exact match
|
||||
next true if name == alias_str
|
||||
|
||||
# Try plural forms
|
||||
next true if name.singularize == alias_str || name.pluralize == alias_str
|
||||
next true if alias_str.singularize == name || alias_str.pluralize == name
|
||||
|
||||
# Try common forms
|
||||
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_name == normalized_alias
|
||||
end
|
||||
end
|
||||
return user_categories.find { |c| c.id == parent_match[:id] } if parent_match
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :user_categories
|
||||
|
||||
def get_plaid_category_details(plaid_category_name)
|
||||
detailed_plaid_categories.find { |c| c[:key] == plaid_category_name.downcase.to_sym }
|
||||
end
|
||||
|
||||
def detailed_plaid_categories
|
||||
CATEGORIES_MAP.flat_map do |parent_key, parent_data|
|
||||
parent_data[:detailed_categories].map do |child_key, child_data|
|
||||
{
|
||||
key: child_key,
|
||||
classification: child_data[:classification],
|
||||
aliases: child_data[:aliases],
|
||||
parent_key: parent_key,
|
||||
parent_aliases: parent_data[:aliases]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_user_categories
|
||||
user_categories.map do |user_category|
|
||||
{
|
||||
id: user_category.id,
|
||||
classification: user_category.classification,
|
||||
name: normalize_user_category_name(user_category.name)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_user_category_name(name)
|
||||
name.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip
|
||||
end
|
||||
end
|
461
app/models/provider/plaid/category_taxonomy.rb
Normal file
461
app/models/provider/plaid/category_taxonomy.rb
Normal file
|
@ -0,0 +1,461 @@
|
|||
# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
module Provider::Plaid::CategoryTaxonomy
|
||||
CATEGORIES_MAP = {
|
||||
income: {
|
||||
classification: :income,
|
||||
aliases: [ "income", "revenue", "earnings" ],
|
||||
detailed_categories: {
|
||||
income_dividends: {
|
||||
classification: :income,
|
||||
aliases: [ "dividend", "stock income", "dividend income", "dividend earnings" ]
|
||||
},
|
||||
income_interest_earned: {
|
||||
classification: :income,
|
||||
aliases: [ "interest", "bank interest", "interest earned", "interest income" ]
|
||||
},
|
||||
income_retirement_pension: {
|
||||
classification: :income,
|
||||
aliases: [ "retirement", "pension" ]
|
||||
},
|
||||
income_tax_refund: {
|
||||
classification: :income,
|
||||
aliases: [ "tax refund" ]
|
||||
},
|
||||
income_unemployment: {
|
||||
classification: :income,
|
||||
aliases: [ "unemployment" ]
|
||||
},
|
||||
income_wages: {
|
||||
classification: :income,
|
||||
aliases: [ "wage", "salary", "paycheck" ]
|
||||
},
|
||||
income_other_income: {
|
||||
classification: :income,
|
||||
aliases: [ "other income", "misc income" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
loan_payments: {
|
||||
classification: :expense,
|
||||
aliases: [ "loan payment", "debt payment", "loan", "debt", "payment" ],
|
||||
detailed_categories: {
|
||||
loan_payments_car_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "car payment", "auto loan" ]
|
||||
},
|
||||
loan_payments_credit_card_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "credit card", "card payment" ]
|
||||
},
|
||||
loan_payments_personal_loan_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal loan", "loan payment" ]
|
||||
},
|
||||
loan_payments_mortgage_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "mortgage", "home loan" ]
|
||||
},
|
||||
loan_payments_student_loan_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "student loan", "education loan" ]
|
||||
},
|
||||
loan_payments_other_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "loan", "loan payment" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
bank_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "bank fee", "service charge", "fee", "misc fees" ],
|
||||
detailed_categories: {
|
||||
bank_fees_atm_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "atm fee", "withdrawal fee" ]
|
||||
},
|
||||
bank_fees_foreign_transaction_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "foreign fee", "international fee" ]
|
||||
},
|
||||
bank_fees_insufficient_funds: {
|
||||
classification: :expense,
|
||||
aliases: [ "nsf fee", "overdraft" ]
|
||||
},
|
||||
bank_fees_interest_charge: {
|
||||
classification: :expense,
|
||||
aliases: [ "interest charge", "finance charge" ]
|
||||
},
|
||||
bank_fees_overdraft_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "overdraft fee" ]
|
||||
},
|
||||
bank_fees_other_bank_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "bank fee", "service charge" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
entertainment: {
|
||||
classification: :expense,
|
||||
aliases: [ "entertainment", "recreation" ],
|
||||
detailed_categories: {
|
||||
entertainment_casinos_and_gambling: {
|
||||
classification: :expense,
|
||||
aliases: [ "casino", "gambling" ]
|
||||
},
|
||||
entertainment_music_and_audio: {
|
||||
classification: :expense,
|
||||
aliases: [ "music", "concert" ]
|
||||
},
|
||||
entertainment_sporting_events_amusement_parks_and_museums: {
|
||||
classification: :expense,
|
||||
aliases: [ "event", "amusement", "museum" ]
|
||||
},
|
||||
entertainment_tv_and_movies: {
|
||||
classification: :expense,
|
||||
aliases: [ "movie", "streaming" ]
|
||||
},
|
||||
entertainment_video_games: {
|
||||
classification: :expense,
|
||||
aliases: [ "game", "gaming" ]
|
||||
},
|
||||
entertainment_other_entertainment: {
|
||||
classification: :expense,
|
||||
aliases: [ "entertainment", "recreation" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
food_and_drink: {
|
||||
classification: :expense,
|
||||
aliases: [ "food", "dining", "food and drink", "food & drink" ],
|
||||
detailed_categories: {
|
||||
food_and_drink_beer_wine_and_liquor: {
|
||||
classification: :expense,
|
||||
aliases: [ "alcohol", "liquor", "beer", "wine", "bar", "pub" ]
|
||||
},
|
||||
food_and_drink_coffee: {
|
||||
classification: :expense,
|
||||
aliases: [ "coffee", "cafe", "coffee shop" ]
|
||||
},
|
||||
food_and_drink_fast_food: {
|
||||
classification: :expense,
|
||||
aliases: [ "fast food", "takeout" ]
|
||||
},
|
||||
food_and_drink_groceries: {
|
||||
classification: :expense,
|
||||
aliases: [ "grocery", "supermarket", "grocery store" ]
|
||||
},
|
||||
food_and_drink_restaurant: {
|
||||
classification: :expense,
|
||||
aliases: [ "restaurant", "dining" ]
|
||||
},
|
||||
food_and_drink_vending_machines: {
|
||||
classification: :expense,
|
||||
aliases: [ "vending" ]
|
||||
},
|
||||
food_and_drink_other_food_and_drink: {
|
||||
classification: :expense,
|
||||
aliases: [ "food", "drink" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
general_merchandise: {
|
||||
classification: :expense,
|
||||
aliases: [ "shopping", "retail" ],
|
||||
detailed_categories: {
|
||||
general_merchandise_bookstores_and_newsstands: {
|
||||
classification: :expense,
|
||||
aliases: [ "book", "newsstand" ]
|
||||
},
|
||||
general_merchandise_clothing_and_accessories: {
|
||||
classification: :expense,
|
||||
aliases: [ "clothing", "apparel" ]
|
||||
},
|
||||
general_merchandise_convenience_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "convenience" ]
|
||||
},
|
||||
general_merchandise_department_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "department store" ]
|
||||
},
|
||||
general_merchandise_discount_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "discount store" ]
|
||||
},
|
||||
general_merchandise_electronics: {
|
||||
classification: :expense,
|
||||
aliases: [ "electronic", "computer" ]
|
||||
},
|
||||
general_merchandise_gifts_and_novelties: {
|
||||
classification: :expense,
|
||||
aliases: [ "gift", "souvenir" ]
|
||||
},
|
||||
general_merchandise_office_supplies: {
|
||||
classification: :expense,
|
||||
aliases: [ "office supply" ]
|
||||
},
|
||||
general_merchandise_online_marketplaces: {
|
||||
classification: :expense,
|
||||
aliases: [ "online shopping" ]
|
||||
},
|
||||
general_merchandise_pet_supplies: {
|
||||
classification: :expense,
|
||||
aliases: [ "pet supply", "pet food" ]
|
||||
},
|
||||
general_merchandise_sporting_goods: {
|
||||
classification: :expense,
|
||||
aliases: [ "sporting good", "sport" ]
|
||||
},
|
||||
general_merchandise_superstores: {
|
||||
classification: :expense,
|
||||
aliases: [ "superstore", "retail" ]
|
||||
},
|
||||
general_merchandise_tobacco_and_vape: {
|
||||
classification: :expense,
|
||||
aliases: [ "tobacco", "smoke" ]
|
||||
},
|
||||
general_merchandise_other_general_merchandise: {
|
||||
classification: :expense,
|
||||
aliases: [ "shopping", "merchandise" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
home_improvement: {
|
||||
classification: :expense,
|
||||
aliases: [ "home", "house", "house renovation", "home improvement", "renovation" ],
|
||||
detailed_categories: {
|
||||
home_improvement_furniture: {
|
||||
classification: :expense,
|
||||
aliases: [ "furniture", "furnishing" ]
|
||||
},
|
||||
home_improvement_hardware: {
|
||||
classification: :expense,
|
||||
aliases: [ "hardware", "tool" ]
|
||||
},
|
||||
home_improvement_repair_and_maintenance: {
|
||||
classification: :expense,
|
||||
aliases: [ "repair", "maintenance" ]
|
||||
},
|
||||
home_improvement_security: {
|
||||
classification: :expense,
|
||||
aliases: [ "security", "alarm" ]
|
||||
},
|
||||
home_improvement_other_home_improvement: {
|
||||
classification: :expense,
|
||||
aliases: [ "home improvement", "renovation" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
medical: {
|
||||
classification: :expense,
|
||||
aliases: [ "medical", "healthcare", "health" ],
|
||||
detailed_categories: {
|
||||
medical_dental_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "dental", "dentist" ]
|
||||
},
|
||||
medical_eye_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "eye", "optometrist" ]
|
||||
},
|
||||
medical_nursing_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "nursing", "care" ]
|
||||
},
|
||||
medical_pharmacies_and_supplements: {
|
||||
classification: :expense,
|
||||
aliases: [ "pharmacy", "prescription" ]
|
||||
},
|
||||
medical_primary_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "doctor", "medical" ]
|
||||
},
|
||||
medical_veterinary_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "vet", "veterinary" ]
|
||||
},
|
||||
medical_other_medical: {
|
||||
classification: :expense,
|
||||
aliases: [ "medical", "healthcare" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
personal_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal care", "grooming" ],
|
||||
detailed_categories: {
|
||||
personal_care_gyms_and_fitness_centers: {
|
||||
classification: :expense,
|
||||
aliases: [ "gym", "fitness", "exercise", "sport" ]
|
||||
},
|
||||
personal_care_hair_and_beauty: {
|
||||
classification: :expense,
|
||||
aliases: [ "salon", "beauty" ]
|
||||
},
|
||||
personal_care_laundry_and_dry_cleaning: {
|
||||
classification: :expense,
|
||||
aliases: [ "laundry", "cleaning" ]
|
||||
},
|
||||
personal_care_other_personal_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal care", "grooming" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
general_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "service", "professional service" ],
|
||||
detailed_categories: {
|
||||
general_services_accounting_and_financial_planning: {
|
||||
classification: :expense,
|
||||
aliases: [ "accountant", "financial advisor" ]
|
||||
},
|
||||
general_services_automotive: {
|
||||
classification: :expense,
|
||||
aliases: [ "auto repair", "mechanic", "vehicle", "car", "car care", "car maintenance", "vehicle maintenance" ]
|
||||
},
|
||||
general_services_childcare: {
|
||||
classification: :expense,
|
||||
aliases: [ "childcare", "daycare" ]
|
||||
},
|
||||
general_services_consulting_and_legal: {
|
||||
classification: :expense,
|
||||
aliases: [ "legal", "attorney" ]
|
||||
},
|
||||
general_services_education: {
|
||||
classification: :expense,
|
||||
aliases: [ "education", "tuition" ]
|
||||
},
|
||||
general_services_insurance: {
|
||||
classification: :expense,
|
||||
aliases: [ "insurance", "premium" ]
|
||||
},
|
||||
general_services_postage_and_shipping: {
|
||||
classification: :expense,
|
||||
aliases: [ "shipping", "postage" ]
|
||||
},
|
||||
general_services_storage: {
|
||||
classification: :expense,
|
||||
aliases: [ "storage" ]
|
||||
},
|
||||
general_services_other_general_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "service" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
government_and_non_profit: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "non-profit" ],
|
||||
detailed_categories: {
|
||||
government_and_non_profit_donations: {
|
||||
classification: :expense,
|
||||
aliases: [ "donation", "charity", "charitable", "charitable donation", "giving", "gifts and donations", "gifts & donations" ]
|
||||
},
|
||||
government_and_non_profit_government_departments_and_agencies: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "agency" ]
|
||||
},
|
||||
government_and_non_profit_tax_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "tax payment", "tax" ]
|
||||
},
|
||||
government_and_non_profit_other_government_and_non_profit: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "non-profit" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
transportation: {
|
||||
classification: :expense,
|
||||
aliases: [ "transportation", "travel" ],
|
||||
detailed_categories: {
|
||||
transportation_bikes_and_scooters: {
|
||||
classification: :expense,
|
||||
aliases: [ "bike", "scooter" ]
|
||||
},
|
||||
transportation_gas: {
|
||||
classification: :expense,
|
||||
aliases: [ "gas", "fuel" ]
|
||||
},
|
||||
transportation_parking: {
|
||||
classification: :expense,
|
||||
aliases: [ "parking" ]
|
||||
},
|
||||
transportation_public_transit: {
|
||||
classification: :expense,
|
||||
aliases: [ "transit", "bus" ]
|
||||
},
|
||||
transportation_taxis_and_ride_shares: {
|
||||
classification: :expense,
|
||||
aliases: [ "taxi", "rideshare" ]
|
||||
},
|
||||
transportation_tolls: {
|
||||
classification: :expense,
|
||||
aliases: [ "toll" ]
|
||||
},
|
||||
transportation_other_transportation: {
|
||||
classification: :expense,
|
||||
aliases: [ "transportation", "travel" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
travel: {
|
||||
classification: :expense,
|
||||
aliases: [ "travel", "vacation", "trip", "sabbatical" ],
|
||||
detailed_categories: {
|
||||
travel_flights: {
|
||||
classification: :expense,
|
||||
aliases: [ "flight", "airfare" ]
|
||||
},
|
||||
travel_lodging: {
|
||||
classification: :expense,
|
||||
aliases: [ "hotel", "lodging" ]
|
||||
},
|
||||
travel_rental_cars: {
|
||||
classification: :expense,
|
||||
aliases: [ "rental car" ]
|
||||
},
|
||||
travel_other_travel: {
|
||||
classification: :expense,
|
||||
aliases: [ "travel", "trip" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
rent_and_utilities: {
|
||||
classification: :expense,
|
||||
aliases: [ "utilities", "housing", "house", "home", "rent", "rent & utilities" ],
|
||||
detailed_categories: {
|
||||
rent_and_utilities_gas_and_electricity: {
|
||||
classification: :expense,
|
||||
aliases: [ "utility", "electric" ]
|
||||
},
|
||||
rent_and_utilities_internet_and_cable: {
|
||||
classification: :expense,
|
||||
aliases: [ "internet", "cable" ]
|
||||
},
|
||||
rent_and_utilities_rent: {
|
||||
classification: :expense,
|
||||
aliases: [ "rent", "lease" ]
|
||||
},
|
||||
rent_and_utilities_sewage_and_waste_management: {
|
||||
classification: :expense,
|
||||
aliases: [ "sewage", "waste" ]
|
||||
},
|
||||
rent_and_utilities_telephone: {
|
||||
classification: :expense,
|
||||
aliases: [ "phone", "telephone" ]
|
||||
},
|
||||
rent_and_utilities_water: {
|
||||
classification: :expense,
|
||||
aliases: [ "water" ]
|
||||
},
|
||||
rent_and_utilities_other_utilities: {
|
||||
classification: :expense,
|
||||
aliases: [ "utility" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
class ProviderMerchant < Merchant
|
||||
enum :source, { plaid: "plaid", synth: "synth", ai: "ai" }
|
||||
|
||||
validates :name, uniqueness: { scope: [ :source, :website_url ] }
|
||||
validates :name, uniqueness: { scope: [ :source ] }
|
||||
end
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor
|
||||
def label
|
||||
"Auto-categorize transactions"
|
||||
if rule.family.self_hoster?
|
||||
"Auto-categorize transactions with AI ($$)"
|
||||
else
|
||||
"Auto-categorize transactions"
|
||||
end
|
||||
end
|
||||
|
||||
def execute(transaction_scope, value: nil, ignore_attribute_locks: false)
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor
|
||||
def label
|
||||
"Auto-detect merchants"
|
||||
if rule.family.self_hoster?
|
||||
"Auto-detect merchants with AI ($$)"
|
||||
else
|
||||
"Auto-detect merchants"
|
||||
end
|
||||
end
|
||||
|
||||
def execute(transaction_scope, value: nil, ignore_attribute_locks: false)
|
||||
|
|
|
@ -12,11 +12,21 @@ class Rule::Registry::TransactionResource < Rule::Registry
|
|||
end
|
||||
|
||||
def action_executors
|
||||
[
|
||||
enabled_executors = [
|
||||
Rule::ActionExecutor::SetTransactionCategory.new(rule),
|
||||
Rule::ActionExecutor::SetTransactionTags.new(rule),
|
||||
Rule::ActionExecutor::AutoDetectMerchants.new(rule),
|
||||
Rule::ActionExecutor::AutoCategorize.new(rule)
|
||||
Rule::ActionExecutor::SetTransactionTags.new(rule)
|
||||
]
|
||||
|
||||
if ai_enabled?
|
||||
enabled_executors << Rule::ActionExecutor::AutoCategorize.new(rule)
|
||||
enabled_executors << Rule::ActionExecutor::AutoDetectMerchants.new(rule)
|
||||
end
|
||||
|
||||
enabled_executors
|
||||
end
|
||||
|
||||
private
|
||||
def ai_enabled?
|
||||
Provider::Registry.get_provider(:openai).present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,19 +21,19 @@ class Sync < ApplicationRecord
|
|||
update!(data: data) if data
|
||||
|
||||
complete! unless has_pending_child_syncs?
|
||||
rescue StandardError => error
|
||||
fail! error
|
||||
raise error if Rails.env.development?
|
||||
ensure
|
||||
|
||||
Rails.logger.info("Sync completed, starting post-sync")
|
||||
|
||||
syncable.post_sync(self) unless has_pending_child_syncs?
|
||||
|
||||
if has_parent?
|
||||
notify_parent_of_completion!
|
||||
else
|
||||
syncable.post_sync(self)
|
||||
end
|
||||
|
||||
Rails.logger.info("Post-sync completed")
|
||||
rescue StandardError => error
|
||||
fail! error
|
||||
raise error if Rails.env.development?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,4 +14,14 @@ class Transaction < ApplicationRecord
|
|||
Search.new(params).build_query(all)
|
||||
end
|
||||
end
|
||||
|
||||
def set_category!(category)
|
||||
if category.is_a?(String)
|
||||
category = entry.account.family.categories.find_or_create_by!(
|
||||
name: category
|
||||
)
|
||||
end
|
||||
|
||||
update!(category: category)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
<%= button_to "Use defaults (recommended)", bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
|
|
|
@ -10,20 +10,18 @@
|
|||
</div>
|
||||
<div class="justify-self-end">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
|
||||
|
||||
<% if category.transactions.any? %>
|
||||
<%= link_to new_category_deletion_path(category),
|
||||
<% if category.transactions.any? %>
|
||||
<%= link_to new_category_deletion_path(category),
|
||||
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
|
||||
<span class="text-sm"><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
|
||||
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
|
||||
<span class="text-sm"><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".edit") do %>
|
||||
<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium"><%= t(".categories") %></h1>
|
||||
|
||||
<%= link_to new_category_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 %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_destructive_item "Delete all", destroy_all_categories_path, turbo_confirm: {
|
||||
title: "Delete all categories?",
|
||||
body: "All of your transactions will become uncategorized and this cannot be undone.",
|
||||
accept: "Delete all categories",
|
||||
} %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_category_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 %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||
<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: "destroyField" } %>
|
||||
|
||||
<div class="grow flex gap-2 items-center h-full">
|
||||
<div class="grow shrink-0">
|
||||
<div class="grow">
|
||||
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -21,7 +21,17 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2 mb-2 py-4">
|
||||
<%= lucide_icon("circle-alert", class: "w-4 h-4 text-secondary") %>
|
||||
<p class="text-sm text-secondary">
|
||||
AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white shadow-border-xs rounded-xl p-4">
|
||||
|
||||
<% if @rules.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
<%# locals: (content:, reload_on_close:) -%>
|
||||
<%# locals: (content:, reload_on_close:, overflow_visible: false) -%>
|
||||
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<dialog class="focus:outline-none m-auto bg-container shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit overflow-auto" data-controller="modal" data-action="mousedown->modal#clickOutside" data-modal-reload-on-close-value="<%= reload_on_close %>">
|
||||
<%= tag.dialog(
|
||||
class: class_names(
|
||||
"focus:outline-none m-auto bg-container rounded-2xl max-w-[580px] w-min-content h-fit shadow-border-xs",
|
||||
overflow_visible ? "overflow-visible" : "overflow-auto"
|
||||
),
|
||||
data: {
|
||||
controller: "modal",
|
||||
action: "mousedown->modal#clickOutside",
|
||||
modal_reload_on_close_value: reload_on_close
|
||||
}
|
||||
) do %>
|
||||
<div class="flex flex-col">
|
||||
<%= content %>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (title:, content:, subtitle: nil) %>
|
||||
<%# locals: (title:, content:, subtitle: nil, overflow_visible: false) %>
|
||||
|
||||
<%= modal do %>
|
||||
<%= modal overflow_visible: overflow_visible do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[450px]">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between items-center">
|
||||
|
|
|
@ -13,11 +13,11 @@
|
|||
<div class="max-w-full">
|
||||
<%= 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">
|
||||
<%= entry.display_name.first.upcase %>
|
||||
<%= entry.name.first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate">
|
||||
<%= link_to entry.display_name,
|
||||
<%= link_to entry.name,
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<%= render "shared/circle_logo",
|
||||
name: entry.display_name,
|
||||
name: entry.name,
|
||||
size: "sm" %>
|
||||
<% end %>
|
||||
|
||||
|
@ -35,7 +35,7 @@
|
|||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= link_to(
|
||||
transaction.transfer? ? transaction.transfer.name : entry.display_name,
|
||||
transaction.transfer? ? transaction.transfer.name : entry.name,
|
||||
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
<%= f.text_field @entry.enriched_at.present? ? :enriched_name : :name,
|
||||
<%= f.text_field :name,
|
||||
label: t(".name_label"),
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class="truncate text-primary">
|
||||
<%= link_to entry.display_name,
|
||||
<%= link_to entry.name,
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
|
|
@ -15,7 +15,7 @@ en:
|
|||
form:
|
||||
placeholder: Category name
|
||||
index:
|
||||
bootstrap: Use default categories
|
||||
bootstrap: Use defaults (recommended)
|
||||
categories: Categories
|
||||
categories_expenses: Expense categories
|
||||
categories_incomes: Income categories
|
||||
|
|
|
@ -68,6 +68,7 @@ Rails.application.routes.draw do
|
|||
resources :deletions, only: %i[new create], module: :category
|
||||
|
||||
post :bootstrap, on: :collection
|
||||
delete :destroy_all, on: :collection
|
||||
end
|
||||
|
||||
resources :budgets, only: %i[index show edit update], param: :month_year do
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
class UserRuleNotifications < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :users, :rule_prompts_disabled, :boolean, default: false
|
||||
add_column :users, :rule_prompt_dismissed_at, :datetime
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
class CreateRules < ActiveRecord::Migration[7.2]
|
||||
class AddRulesEngine < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :rules, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
|
@ -26,5 +26,8 @@ class CreateRules < ActiveRecord::Migration[7.2]
|
|||
t.string :value
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_column :users, :rule_prompts_disabled, :boolean, default: false
|
||||
add_column :users, :rule_prompt_dismissed_at, :datetime
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
class DataEnrichmentsAndLocks < ActiveRecord::Migration[7.2]
|
||||
class AddDataEnrichments < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :data_enrichments, id: :uuid do |t|
|
||||
t.references :enrichable, polymorphic: true, null: false, type: :uuid
|
|
@ -1,4 +1,4 @@
|
|||
class ProviderMerchants < ActiveRecord::Migration[7.2]
|
||||
class MerchantAndCategoryEnrichment < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_column_null :merchants, :family_id, true
|
||||
change_column_null :merchants, :color, true
|
||||
|
@ -19,11 +19,16 @@ class ProviderMerchants < ActiveRecord::Migration[7.2]
|
|||
|
||||
change_column_null :merchants, :type, false
|
||||
|
||||
# Provider specific columns
|
||||
add_column :merchants, :source, :string
|
||||
add_column :merchants, :provider_merchant_id, :string
|
||||
|
||||
add_index :merchants, [ :family_id, :name ], unique: true, where: "type = 'FamilyMerchant'"
|
||||
add_index :merchants, [ :source, :name, :website_url ], unique: true, where: "type = 'ProviderMerchant'"
|
||||
add_index :merchants, [ :source, :name ], unique: true, where: "type = 'ProviderMerchant'"
|
||||
|
||||
add_column :transactions, :plaid_category, :string
|
||||
add_column :transactions, :plaid_category_detailed, :string
|
||||
|
||||
remove_column :entries, :enriched_name, :string
|
||||
remove_column :entries, :enriched_at, :datetime
|
||||
end
|
||||
end
|
8
db/schema.rb
generated
8
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_15_125256) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_04_16_235758) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -199,8 +199,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_15_125256) do
|
|||
t.text "notes"
|
||||
t.boolean "excluded", default: false
|
||||
t.string "plaid_id"
|
||||
t.datetime "enriched_at"
|
||||
t.string "enriched_name"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||
|
@ -638,6 +636,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_15_125256) do
|
|||
t.uuid "category_id"
|
||||
t.uuid "merchant_id"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "plaid_category"
|
||||
t.string "plaid_category_detailed"
|
||||
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
||||
end
|
||||
|
@ -674,9 +674,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_15_125256) do
|
|||
t.uuid "last_viewed_chat_id"
|
||||
t.boolean "show_ai_sidebar", default: true
|
||||
t.boolean "ai_enabled", default: false, null: false
|
||||
t.string "theme", default: "system"
|
||||
t.boolean "rule_prompts_disabled", default: false
|
||||
t.datetime "rule_prompt_dismissed_at"
|
||||
t.string "theme", default: "system"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["family_id"], name: "index_users_on_family_id"
|
||||
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
|
||||
|
|
|
@ -84,7 +84,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
|||
end
|
||||
|
||||
test "bootstrap" do
|
||||
assert_difference "Category.count", 10 do
|
||||
assert_difference "Category.count", 12 do
|
||||
post bootstrap_categories_url
|
||||
end
|
||||
|
||||
|
|
136
test/models/provider/plaid/category_alias_matcher_test.rb
Normal file
136
test/models/provider/plaid/category_alias_matcher_test.rb
Normal file
|
@ -0,0 +1,136 @@
|
|||
require "test_helper"
|
||||
|
||||
class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
|
||||
# User income categories
|
||||
@income = @family.categories.create!(name: "Income", classification: "income")
|
||||
@dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income, classification: "income")
|
||||
@interest_income = @family.categories.create!(name: "Interest Income", parent: @income, classification: "income")
|
||||
|
||||
# User expense categories
|
||||
@loan_payments = @family.categories.create!(name: "Loan Payments")
|
||||
@fees = @family.categories.create!(name: "Fees")
|
||||
@entertainment = @family.categories.create!(name: "Entertainment")
|
||||
|
||||
@food_and_drink = @family.categories.create!(name: "Food & Drink")
|
||||
@groceries = @family.categories.create!(name: "Groceries", parent: @food_and_drink)
|
||||
@restaurant = @family.categories.create!(name: "Restaurant", parent: @food_and_drink)
|
||||
|
||||
@shopping = @family.categories.create!(name: "Shopping")
|
||||
@clothing = @family.categories.create!(name: "Clothing", parent: @shopping)
|
||||
|
||||
@home = @family.categories.create!(name: "Home")
|
||||
@medical = @family.categories.create!(name: "Medical")
|
||||
@personal_care = @family.categories.create!(name: "Personal Care")
|
||||
@transportation = @family.categories.create!(name: "Transportation")
|
||||
@trips = @family.categories.create!(name: "Trips")
|
||||
|
||||
@services = @family.categories.create!(name: "Services")
|
||||
@car = @family.categories.create!(name: "Car", parent: @services)
|
||||
|
||||
@giving = @family.categories.create!(name: "Giving")
|
||||
|
||||
@matcher = Provider::Plaid::CategoryAliasMatcher.new(@family.categories)
|
||||
end
|
||||
|
||||
test "matches expense categories" do
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_car_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_credit_card_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_personal_loan_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_mortgage_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_student_loan_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_other_payment")
|
||||
assert_equal @fees, @matcher.match("bank_fees_atm_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_foreign_transaction_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_insufficient_funds")
|
||||
assert_equal @fees, @matcher.match("bank_fees_interest_charge")
|
||||
assert_equal @fees, @matcher.match("bank_fees_overdraft_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_other_bank_fees")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_casinos_and_gambling")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_music_and_audio")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_sporting_events_amusement_parks_and_museums")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_tv_and_movies")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_video_games")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_other_entertainment")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_beer_wine_and_liquor")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_coffee")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_fast_food")
|
||||
assert_equal @groceries, @matcher.match("food_and_drink_groceries")
|
||||
assert_equal @restaurant, @matcher.match("food_and_drink_restaurant")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_vending_machines")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_other_food_and_drink")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_bookstores_and_newsstands")
|
||||
assert_equal @clothing, @matcher.match("general_merchandise_clothing_and_accessories")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_convenience_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_department_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_discount_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_electronics")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_gifts_and_novelties")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_office_supplies")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_online_marketplaces")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_pet_supplies")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_sporting_goods")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_superstores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_tobacco_and_vape")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_other_general_merchandise")
|
||||
assert_equal @home, @matcher.match("home_improvement_furniture")
|
||||
assert_equal @home, @matcher.match("home_improvement_hardware")
|
||||
assert_equal @home, @matcher.match("home_improvement_repair_and_maintenance")
|
||||
assert_equal @home, @matcher.match("home_improvement_security")
|
||||
assert_equal @home, @matcher.match("home_improvement_other_home_improvement")
|
||||
assert_equal @medical, @matcher.match("medical_dental_care")
|
||||
assert_equal @medical, @matcher.match("medical_eye_care")
|
||||
assert_equal @medical, @matcher.match("medical_nursing_care")
|
||||
assert_equal @medical, @matcher.match("medical_pharmacies_and_supplements")
|
||||
assert_equal @medical, @matcher.match("medical_primary_care")
|
||||
assert_equal @medical, @matcher.match("medical_veterinary_services")
|
||||
assert_equal @medical, @matcher.match("medical_other_medical")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_gyms_and_fitness_centers")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_hair_and_beauty")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_laundry_and_dry_cleaning")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_other_personal_care")
|
||||
assert_equal @services, @matcher.match("general_services_accounting_and_financial_planning")
|
||||
assert_equal @car, @matcher.match("general_services_automotive")
|
||||
assert_equal @services, @matcher.match("general_services_childcare")
|
||||
assert_equal @services, @matcher.match("general_services_consulting_and_legal")
|
||||
assert_equal @services, @matcher.match("general_services_education")
|
||||
assert_equal @services, @matcher.match("general_services_insurance")
|
||||
assert_equal @services, @matcher.match("general_services_postage_and_shipping")
|
||||
assert_equal @services, @matcher.match("general_services_storage")
|
||||
assert_equal @services, @matcher.match("general_services_other_general_services")
|
||||
assert_equal @giving, @matcher.match("government_and_non_profit_donations")
|
||||
assert_nil @matcher.match("government_and_non_profit_government_departments_and_agencies")
|
||||
assert_nil @matcher.match("government_and_non_profit_tax_payment")
|
||||
assert_nil @matcher.match("government_and_non_profit_other_government_and_non_profit")
|
||||
assert_equal @transportation, @matcher.match("transportation_bikes_and_scooters")
|
||||
assert_equal @transportation, @matcher.match("transportation_gas")
|
||||
assert_equal @transportation, @matcher.match("transportation_parking")
|
||||
assert_equal @transportation, @matcher.match("transportation_public_transit")
|
||||
assert_equal @transportation, @matcher.match("transportation_taxis_and_ride_shares")
|
||||
assert_equal @transportation, @matcher.match("transportation_tolls")
|
||||
assert_equal @transportation, @matcher.match("transportation_other_transportation")
|
||||
assert_equal @trips, @matcher.match("travel_flights")
|
||||
assert_equal @trips, @matcher.match("travel_lodging")
|
||||
assert_equal @trips, @matcher.match("travel_rental_cars")
|
||||
assert_equal @trips, @matcher.match("travel_other_travel")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_gas_and_electricity")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_internet_and_cable")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_rent")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_sewage_and_waste_management")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_telephone")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_water")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_other_utilities")
|
||||
end
|
||||
|
||||
test "matches income categories" do
|
||||
assert_equal @dividend_income, @matcher.match("income_dividends")
|
||||
assert_equal @interest_income, @matcher.match("income_interest_earned")
|
||||
assert_equal @income, @matcher.match("income_tax_refund")
|
||||
assert_equal @income, @matcher.match("income_retirement_pension")
|
||||
assert_equal @income, @matcher.match("income_unemployment")
|
||||
assert_equal @income, @matcher.match("income_wages")
|
||||
assert_equal @income, @matcher.match("income_other_income")
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue