1
0
Fork 0
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:
Zach Gollwitzer 2025-04-17 14:28:25 -04:00
parent d60d57f10f
commit 69159fdc86
47 changed files with 917 additions and 109 deletions

View file

@ -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

View file

@ -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

View file

@ -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: [] } ]
)

View file

@ -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

View file

@ -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
##

View file

@ -34,7 +34,7 @@ module EntriesHelper
entry.date,
format_money(entry.amount_money),
entry.account.name,
entry.display_name
entry.name
].join("")
end
end

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = {}

View file

@ -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}"

View file

@ -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)

View 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

View 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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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") %>

View file

@ -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>

View file

@ -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 %>

View file

@ -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">

View file

@ -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 %>

View file

@ -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>

View file

@ -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">

View file

@ -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 %>

View file

@ -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">

View file

@ -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" %>

View file

@ -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",

View file

@ -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" %>

View file

@ -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" %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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"

View file

@ -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

View 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