diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 1a0c15cf..cb3d5852 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -166,7 +166,6 @@ background: #a6a6a6; } } -/* The following Markdown CSS has been removed as requested */ .mt-safe { margin-top: env(safe-area-inset-top); diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index f8379818..72e52292 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -217,6 +217,18 @@ --shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%); --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%); --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%); + + --animate-stroke-fill: stroke-fill 3s 300ms forwards; + + @keyframes stroke-fill { + 0% { + stroke-dashoffset: 43.9822971503; + } + + 100% { + stroke-dashoffset: 0; + } + } } /* Custom shadow borders used for surfaces / containers */ @@ -504,7 +516,7 @@ @layer components { /* Buttons */ .btn { - @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; + @apply inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; @apply transition-all duration-300; } @@ -548,6 +560,14 @@ } } + .btn--outline-destructive { + @apply border border-red-500 text-red-500 hover:bg-gray-50; + + @variant theme-dark { + @apply border-red-400 text-red-400 hover:button-bg-destructive-hover; + } + } + .btn--destructive { @apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400; diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 33b3980a..f003ab31 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -31,7 +31,7 @@ class AccountsController < ApplicationController family.sync_later end - redirect_to accounts_path + redirect_back_or_to accounts_path end private diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 70895a3c..cebfea1f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable + include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable include Pagy::Backend helper_method :require_upgrade?, :subscription_pending? diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 04ffb0e5..b1516f86 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -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 diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index d7d3b169..16fdbebd 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -37,11 +37,15 @@ module AccountableResource def create @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) + @account.lock_saved_attributes! + redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end def update @account.update_with_sync!(account_params.except(:return_to)) + @account.lock_saved_attributes! + redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize) end diff --git a/app/controllers/concerns/notifiable.rb b/app/controllers/concerns/notifiable.rb new file mode 100644 index 00000000..0d8ea384 --- /dev/null +++ b/app/controllers/concerns/notifiable.rb @@ -0,0 +1,58 @@ +module Notifiable + extend ActiveSupport::Concern + + included do + helper_method :render_flash_notifications + helper_method :flash_notification_stream_items + end + + private + def render_flash_notifications + notifications = flash.flat_map { |type, data| resolve_notifications(type, data) }.compact + + view_context.safe_join( + notifications.map { |notification| view_context.render(**notification) } + ) + end + + def flash_notification_stream_items + items = flash.flat_map do |type, data| + notifications = resolve_notifications(type, data) + + if type == "cta" + notifications.map { |notification| turbo_stream.replace("cta", **notification) } + else + notifications.map { |notification| turbo_stream.append("notification-tray", **notification) } + end + end.compact + + # If rendering flash notifications via stream, we mark them as used to avoid + # them being rendered again on the next page load + flash.clear + + items + end + + def resolve_cta(cta) + case cta[:type] + when "category_rule" + { partial: "rules/category_rule_cta", locals: { cta: } } + end + end + + def resolve_notifications(type, data) + case type + when "alert" + [ { partial: "shared/notifications/alert", locals: { message: data } } ] + when "cta" + [ resolve_cta(data) ] + when "loading" + [ { partial: "shared/notifications/loading", locals: { message: data } } ] + when "notice" + messages = Array(data) + messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } } + else + [] + end + end +end diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb new file mode 100644 index 00000000..01531a47 --- /dev/null +++ b/app/controllers/family_merchants_controller.rb @@ -0,0 +1,54 @@ +class FamilyMerchantsController < ApplicationController + before_action :set_merchant, only: %i[edit update destroy] + + def index + @breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ] + + @merchants = Current.family.merchants.alphabetically + + render layout: "settings" + end + + def new + @merchant = FamilyMerchant.new(family: Current.family) + end + + def create + @merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family)) + + if @merchant.save + respond_to do |format| + format.html { redirect_to family_merchants_path, notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + end + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + @merchant.update!(merchant_params) + respond_to do |format| + format.html { redirect_to family_merchants_path, notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + end + end + + def destroy + @merchant.destroy! + redirect_to family_merchants_path, notice: t(".success") + end + + private + + def set_merchant + @merchant = Current.family.merchants.find(params[:id]) + end + + def merchant_params + params.require(:family_merchant).permit(:name, :color) + end +end diff --git a/app/controllers/merchants_controller.rb b/app/controllers/merchants_controller.rb deleted file mode 100644 index cbcf1cdb..00000000 --- a/app/controllers/merchants_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -class MerchantsController < ApplicationController - before_action :set_merchant, only: %i[edit update destroy] - - def index - @merchants = Current.family.merchants.alphabetically - - render layout: "settings" - end - - def new - @merchant = Merchant.new - end - - def create - @merchant = Current.family.merchants.new(merchant_params) - - if @merchant.save - redirect_to merchants_path, notice: t(".success") - else - redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence) - end - end - - def edit - end - - def update - @merchant.update!(merchant_params) - redirect_to merchants_path, notice: t(".success") - end - - def destroy - @merchant.destroy! - redirect_to merchants_path, notice: t(".success") - end - - private - - def set_merchant - @merchant = Current.family.merchants.find(params[:id]) - end - - def merchant_params - params.require(:merchant).permit(:name, :color) - end -end diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb new file mode 100644 index 00000000..318ed60d --- /dev/null +++ b/app/controllers/rules_controller.rb @@ -0,0 +1,77 @@ +class RulesController < ApplicationController + include StreamExtensions + + before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ] + + def index + @rules = Current.family.rules.order(created_at: :desc) + render layout: "settings" + end + + def new + @rule = Current.family.rules.build( + resource_type: params[:resource_type] || "transaction", + ) + end + + def create + @rule = Current.family.rules.build(rule_params) + + if @rule.save + redirect_to confirm_rule_path(@rule) + else + render :new, status: :unprocessable_entity + end + end + + def apply + @rule.update!(active: true) + @rule.apply_later(ignore_attribute_locks: true) + redirect_back_or_to rules_path, notice: "#{@rule.resource_type.humanize} rule activated" + end + + def confirm + end + + def edit + end + + def update + if @rule.update(rule_params) + respond_to do |format| + format.html { redirect_back_or_to rules_path, notice: "Rule updated" } + format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" } + end + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @rule.destroy + redirect_to rules_path, notice: "Rule deleted" + end + + def destroy_all + Current.family.rules.destroy_all + redirect_to rules_path, notice: "All rules deleted" + end + + private + def set_rule + @rule = Current.family.rules.find(params[:id]) + end + + def rule_params + params.require(:rule).permit( + :resource_type, :effective_date, :active, + conditions_attributes: [ + :id, :condition_type, :operator, :value, :_destroy, + sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ] + ], + actions_attributes: [ + :id, :action_type, :value, :_destroy + ] + ) + end +end diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index 151d62c6..bd49459e 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -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 diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb index f70e0aa9..dac6b5be 100644 --- a/app/controllers/transaction_categories_controller.rb +++ b/app/controllers/transaction_categories_controller.rb @@ -1,16 +1,34 @@ class TransactionCategoriesController < ApplicationController + include ActionView::RecordIdentifier + def update @entry = Current.family.entries.transactions.find(params[:transaction_id]) @entry.update!(entry_params) + transaction = @entry.transaction + + if needs_rule_notification?(transaction) + flash[:cta] = { + type: "category_rule", + category_id: transaction.category_id, + category_name: transaction.category.name + } + end + + transaction.lock_saved_attributes! + @entry.lock_saved_attributes! + respond_to do |format| format.html { redirect_back_or_to transaction_path(@entry) } format.turbo_stream do - render turbo_stream: turbo_stream.replace( - "category_menu_transaction_#{@entry.transaction_id}", - partial: "categories/menu", - locals: { transaction: @entry.transaction } - ) + render turbo_stream: [ + turbo_stream.replace( + dom_id(transaction, :category_menu), + partial: "categories/menu", + locals: { transaction: transaction } + ), + *flash_notification_stream_items + ] end end end @@ -19,4 +37,16 @@ class TransactionCategoriesController < ApplicationController def entry_params params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) end + + def needs_rule_notification?(transaction) + return false if Current.user.rule_prompts_disabled + + if Current.user.rule_prompt_dismissed_at.present? + time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at + return false if time_since_last_rule_prompt < 1.day + end + + transaction.saved_change_to_category_id? && + transaction.eligible_for_category_rule? + end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index e4407ee3..77044542 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -54,6 +54,8 @@ class TransactionsController < ApplicationController if @entry.save @entry.sync_account_later + @entry.lock_saved_attributes! + @entry.transaction.lock!(:tag_ids) if @entry.transaction.tags.any? flash[:notice] = "Transaction created" @@ -68,7 +70,19 @@ class TransactionsController < ApplicationController def update if @entry.update(entry_params) + transaction = @entry.transaction + + if needs_rule_notification?(transaction) + flash[:cta] = { + type: "category_rule", + category_id: transaction.category_id, + category_name: transaction.category.name + } + end + @entry.sync_account_later + @entry.lock_saved_attributes! + @entry.transaction.lock!(:tag_ids) if @entry.transaction.tags.any? respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } @@ -79,7 +93,8 @@ class TransactionsController < ApplicationController partial: "transactions/header", locals: { entry: @entry } ), - turbo_stream.replace(@entry) + turbo_stream.replace(@entry), + *flash_notification_stream_items ] end end @@ -89,9 +104,21 @@ class TransactionsController < ApplicationController end private + def needs_rule_notification?(transaction) + return false if Current.user.rule_prompts_disabled + + if Current.user.rule_prompt_dismissed_at.present? + time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at + return false if time_since_last_rule_prompt < 1.day + end + + transaction.saved_change_to_category_id? && + transaction.eligible_for_category_rule? + end + 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: [] } ] ) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 6cfefaec..0bf735f1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -49,6 +49,11 @@ class UsersController < ApplicationController end end + def rule_prompt_settings + @user.update!(rule_prompt_settings_params) + redirect_back_or_to settings_profile_path + end + private def handle_redirect(notice) case user_params[:redirect_to] @@ -72,10 +77,14 @@ class UsersController < ApplicationController user_params[:email].present? && user_params[:email] != @user.email end + def rule_prompt_settings_params + params.require(:user).permit(:rule_prompt_dismissed_at, :rule_prompts_disabled) + end + def user_params params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ] ) end diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb index 7d91a9a6..a33e5457 100644 --- a/app/controllers/valuations_controller.rb +++ b/app/controllers/valuations_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 64321595..316e7900 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -27,24 +27,10 @@ module ApplicationHelper content_for(:header_description) { page_description } end - def family_notifications_stream - turbo_stream_from [ Current.family, :notifications ] if Current.family - end - def family_stream turbo_stream_from Current.family if Current.family end - def render_flash_notifications - notifications = flash.flat_map do |type, message_or_messages| - Array(message_or_messages).map do |message| - render partial: "shared/notification", locals: { type: type, message: message } - end - end - - safe_join(notifications) - end - ## # Helper to open a centered and overlayed modal with custom contents # @@ -53,9 +39,9 @@ module ApplicationHelper #
Content here
# <% end %> # - def modal(options = {}, &block) + def modal(reload_on_close: false, overflow_visible: false, &block) content = capture &block - render partial: "shared/modal", locals: { content:, classes: options[:classes] } + render partial: "shared/modal", locals: { content:, reload_on_close:, overflow_visible: } end ## diff --git a/app/helpers/entries_helper.rb b/app/helpers/entries_helper.rb index e198e6ee..1c3340ae 100644 --- a/app/helpers/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -34,7 +34,7 @@ module EntriesHelper entry.date, format_money(entry.amount_money), entry.account.name, - entry.display_name + entry.name ].join(" • ") end end diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 5e311810..53336757 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -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, class: nil) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 674bea8b..dfe200c9 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -9,7 +9,8 @@ module SettingsHelper { name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path }, { name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path }, { name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path }, - { name: I18n.t("settings.settings_nav.merchants_label"), path: :merchants_path }, + { name: "Rules", path: :rules_path }, + { name: I18n.t("settings.settings_nav.merchants_label"), path: :family_merchants_path }, { name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path }, { name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path } ] diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 8c9d6c50..242c0247 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -22,7 +22,7 @@ export default class extends Controller { this.element.close(); if (this.reloadOnCloseValue) { - window.location.reload(); + Turbo.visit(window.location.href); } } } diff --git a/app/javascript/controllers/rule/actions_controller.js b/app/javascript/controllers/rule/actions_controller.js new file mode 100644 index 00000000..815e027e --- /dev/null +++ b/app/javascript/controllers/rule/actions_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rule--actions" +export default class extends Controller { + static values = { actionExecutors: Array }; + static targets = ["destroyField", "actionValue"]; + + remove(e) { + if (e.params.destroy) { + this.destroyFieldTarget.value = true; + } else { + this.element.remove(); + } + } + + handleActionTypeChange(e) { + const actionExecutor = this.actionExecutorsValue.find( + (executor) => executor.key === e.target.value, + ); + + if (actionExecutor.type === "select") { + this.#updateValueSelectFor(actionExecutor); + this.#showAndEnableValueSelect(); + } else { + this.#hideAndDisableValueSelect(); + } + } + + get valueSelectEl() { + return this.actionValueTarget.querySelector("select"); + } + + #showAndEnableValueSelect() { + this.actionValueTarget.classList.remove("hidden"); + this.valueSelectEl.disabled = false; + } + + #hideAndDisableValueSelect() { + this.actionValueTarget.classList.add("hidden"); + this.valueSelectEl.disabled = true; + } + + #updateValueSelectFor(actionExecutor) { + // Clear existing options + this.valueSelectEl.innerHTML = ""; + + // Add new options + for (const option of actionExecutor.options) { + const optionEl = document.createElement("option"); + optionEl.value = option[1]; + optionEl.textContent = option[0]; + this.valueSelectEl.appendChild(optionEl); + } + } +} diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js new file mode 100644 index 00000000..1cffa119 --- /dev/null +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -0,0 +1,104 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rule--conditions" +export default class extends Controller { + static values = { conditionFilters: Array }; + static targets = [ + "destroyField", + "filterValue", + "operatorSelect", + "subConditionTemplate", + "subConditionsList", + ]; + + addSubCondition() { + const html = this.subConditionTemplateTarget.innerHTML.replaceAll( + "IDX_PLACEHOLDER", + this.#uniqueKey(), + ); + + this.subConditionsListTarget.insertAdjacentHTML("beforeend", html); + } + + remove(e) { + if (e.params.destroy) { + this.destroyFieldTarget.value = true; + this.element.classList.add("hidden"); + } else { + this.element.remove(); + } + } + + handleConditionTypeChange(e) { + const conditionFilter = this.conditionFiltersValue.find( + (filter) => filter.key === e.target.value, + ); + + if (conditionFilter.type === "select") { + this.#buildSelectFor(conditionFilter); + } else { + this.#buildTextInputFor(conditionFilter); + } + + this.#updateOperatorsField(conditionFilter); + } + + get valueInputEl() { + const textInput = this.filterValueTarget.querySelector("input"); + const selectInput = this.filterValueTarget.querySelector("select"); + + return textInput || selectInput; + } + + #updateOperatorsField(conditionFilter) { + this.operatorSelectTarget.innerHTML = ""; + + for (const operator of conditionFilter.operators) { + const optionEl = document.createElement("option"); + optionEl.value = operator[1]; + optionEl.textContent = operator[0]; + this.operatorSelectTarget.appendChild(optionEl); + } + } + + #buildSelectFor(conditionFilter) { + const selectEl = this.#convertFormFieldTo("select", this.valueInputEl); + + for (const option of conditionFilter.options) { + const optionEl = document.createElement("option"); + optionEl.value = option[1]; + optionEl.textContent = option[0]; + selectEl.appendChild(optionEl); + } + + this.valueInputEl.replaceWith(selectEl); + } + + #buildTextInputFor(conditionFilter) { + const textInput = this.#convertFormFieldTo("input", this.valueInputEl); + textInput.placeholder = "Enter a value"; + textInput.type = conditionFilter.type; // "text" || "number" + if (conditionFilter.type === "number") { + textInput.step = conditionFilter.number_step; + } + + this.valueInputEl.replaceWith(textInput); + } + + #convertFormFieldTo(type, el) { + const priorClasses = el.classList; + const priorId = el.id; + const priorName = el.name; + + const newFormField = document.createElement(type); + newFormField.classList.add(...priorClasses); + newFormField.id = priorId; + newFormField.name = priorName; + + return newFormField; + } + + #uniqueKey() { + return Math.random().toString(36).substring(2, 15); + } +} diff --git a/app/javascript/controllers/rules_controller.js b/app/javascript/controllers/rules_controller.js new file mode 100644 index 00000000..0db0e67a --- /dev/null +++ b/app/javascript/controllers/rules_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rules" +export default class extends Controller { + static targets = [ + "conditionTemplate", + "conditionGroupTemplate", + "actionTemplate", + "conditionsList", + "actionsList", + "effectiveDateInput", + ]; + + addConditionGroup() { + this.#appendTemplate( + this.conditionGroupTemplateTarget, + this.conditionsListTarget, + ); + } + + addCondition() { + this.#appendTemplate( + this.conditionTemplateTarget, + this.conditionsListTarget, + ); + } + + addAction() { + this.#appendTemplate(this.actionTemplateTarget, this.actionsListTarget); + } + + clearEffectiveDate() { + this.effectiveDateInputTarget.value = ""; + } + + #appendTemplate(templateEl, listEl) { + const html = templateEl.innerHTML.replaceAll( + "IDX_PLACEHOLDER", + this.#uniqueKey(), + ); + + listEl.insertAdjacentHTML("beforeend", html); + } + + #uniqueKey() { + return Date.now(); + } +} diff --git a/app/jobs/auto_categorize_job.rb b/app/jobs/auto_categorize_job.rb new file mode 100644 index 00000000..c31917a8 --- /dev/null +++ b/app/jobs/auto_categorize_job.rb @@ -0,0 +1,7 @@ +class AutoCategorizeJob < ApplicationJob + queue_as :medium_priority + + def perform(family, transaction_ids: []) + family.auto_categorize_transactions(transaction_ids) + end +end diff --git a/app/jobs/auto_detect_merchants_job.rb b/app/jobs/auto_detect_merchants_job.rb new file mode 100644 index 00000000..eb3713a4 --- /dev/null +++ b/app/jobs/auto_detect_merchants_job.rb @@ -0,0 +1,7 @@ +class AutoDetectMerchantsJob < ApplicationJob + queue_as :medium_priority + + def perform(family, transaction_ids: []) + family.auto_detect_transaction_merchants(transaction_ids) + end +end diff --git a/app/jobs/enrich_transaction_batch_job.rb b/app/jobs/enrich_transaction_batch_job.rb deleted file mode 100644 index 71aac720..00000000 --- a/app/jobs/enrich_transaction_batch_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class EnrichTransactionBatchJob < ApplicationJob - queue_as :low_priority - - def perform(account, batch_size = 100, offset = 0) - account.enrich_transaction_batch(batch_size, offset) - end -end diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb new file mode 100644 index 00000000..fe446d0e --- /dev/null +++ b/app/jobs/rule_job.rb @@ -0,0 +1,7 @@ +class RuleJob < ApplicationJob + queue_as :medium_priority + + def perform(rule, ignore_attribute_locks: false) + rule.apply(ignore_attribute_locks: ignore_attribute_locks) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 970c6ee5..6317fb94 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible + include Syncable, Monetizable, Chartable, Linkable, Convertible, Enrichable validates :name, :balance, :currency, presence: true @@ -83,11 +83,6 @@ class Account < ApplicationRecord accountable.post_sync(sync) - if enrichable? - Rails.logger.info("Enriching transaction data") - enrich_data - end - unless sync.child? family.auto_match_transfers! end @@ -147,6 +142,11 @@ class Account < ApplicationRecord first_entry_date - 1.day end + def lock_saved_attributes! + super + accountable.lock_saved_attributes! + end + def first_valuation entries.valuations.order(:date).first end diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb deleted file mode 100644 index f5f175b4..00000000 --- a/app/models/account/enrichable.rb +++ /dev/null @@ -1,71 +0,0 @@ -module Account::Enrichable - extend ActiveSupport::Concern - - def enrich_data - total_unenriched = entries.transactions - .joins("JOIN transactions at ON at.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") - .where("entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") - .count - - if total_unenriched > 0 - batch_size = 50 - batches = (total_unenriched.to_f / batch_size).ceil - - batches.times do |batch| - EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size) - # EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size) - end - end - end - - def enrich_transaction_batch(batch_size = 50, offset = 0) - transactions_batch = enrichable_transactions.offset(offset).limit(batch_size) - - Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})") - - merchants = {} - - transactions_batch.each do |transaction| - begin - info = transaction.fetch_enrichment_info - - next unless info.present? - - if info.name.present? - merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name) - - if info.icon_url.present? - merchant.icon_url = info.icon_url - end - end - - Account.transaction do - merchant.save! if merchant.present? - transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil? - - transaction.entry.update!( - enriched_at: Time.current, - enriched_name: info.name, - ) - end - rescue => e - Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}") - end - end - end - - private - def enrichable? - family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?) - end - - def enrichable_transactions - transactions.active - .includes(:merchant, :category) - .where( - "entries.enriched_at IS NULL", - "OR merchant_id IS NULL", - "OR category_id IS NULL" - ) - end -end diff --git a/app/models/category.rb b/app/models/category.rb index 56fd3a63..3c63c45a 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -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 diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 2d545ec9..beb3cf23 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -9,6 +9,8 @@ module Accountable end included do + include Enrichable + has_one :account, as: :accountable, touch: true end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb new file mode 100644 index 00000000..e5804786 --- /dev/null +++ b/app/models/concerns/enrichable.rb @@ -0,0 +1,63 @@ +# Enrichable models can have 1+ of their fields enriched by various +# external sources (i.e. Plaid) or internal sources (i.e. Rules) +# +# This module defines how models should, lock, unlock, and edit attributes +# based on the source of the edit. User edits always take highest precedence. +# +# For example: +# +# If a Rule tells us to set the category to "Groceries", but the user later overrides +# a transaction with a category of "Food", we should not override the category again. +# +module Enrichable + extend ActiveSupport::Concern + + InvalidAttributeError = Class.new(StandardError) + + included do + scope :enrichable, ->(attrs) { + attrs = Array(attrs).map(&:to_s) + json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true } + where.not(Arel.sql("#{table_name}.locked_attributes ?| array[:keys]"), keys: attrs) + } + end + + def log_enrichment!(attribute_name:, attribute_value:, source:, metadata: {}) + de = DataEnrichment.find_or_create_by!( + enrichable: self, + attribute_name: attribute_name, + source: source, + ) + + de.value = attribute_value + de.metadata = metadata + de.save! + end + + def locked?(attr) + locked_attributes[attr.to_s].present? + end + + def enrichable?(attr) + !locked?(attr) + end + + def lock!(attr) + update!(locked_attributes: locked_attributes.merge(attr.to_s => Time.current)) + end + + def unlock!(attr) + update!(locked_attributes: locked_attributes.except(attr.to_s)) + end + + def lock_saved_attributes! + saved_changes.keys.reject { |attr| ignored_enrichable_attributes.include?(attr) }.each do |attr| + lock!(attr) + end + end + + private + def ignored_enrichable_attributes + %w[id updated_at created_at] + end +end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb new file mode 100644 index 00000000..df6a0d67 --- /dev/null +++ b/app/models/data_enrichment.rb @@ -0,0 +1,5 @@ +class DataEnrichment < ApplicationRecord + belongs_to :enrichable, polymorphic: true + + enum :source, { rule: "rule", plaid: "plaid", synth: "synth", ai: "ai" } +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 181b9806..db479b08 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -40,7 +40,7 @@ class Demo::Generator create_tags!(family) create_categories!(family) create_merchants!(family) - + create_rules!(family) puts "tags, categories, merchants created for #{family_name}" create_credit_card_account!(family) @@ -152,7 +152,7 @@ class Demo::Generator Security::Price.destroy_all end - def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false, currency: "USD") + def create_family_and_user!(family_name, user_email, currency: "USD") base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0" id = Digest::UUID.uuid_v5(base_uuid, family_name) @@ -161,7 +161,6 @@ class Demo::Generator name: family_name, currency: currency, stripe_subscription_status: "active", - data_enrichment_enabled: data_enrichment_enabled, locale: "en", country: "US", timezone: "America/New_York", @@ -185,6 +184,20 @@ class Demo::Generator onboarded_at: Time.current end + def create_rules!(family) + family.rules.create!( + effective_date: 1.year.ago.to_date, + active: true, + resource_type: "transaction", + conditions: [ + Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") + ] + ) + end + def create_tags!(family) [ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag| family.tags.create!(name: tag) @@ -192,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") @@ -206,7 +219,7 @@ class Demo::Generator "Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ] merchants.each do |merchant| - family.merchants.create!(name: merchant, color: COLORS.sample) + FamilyMerchant.create!(name: merchant, family: family, color: COLORS.sample) end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 0f35cd39..c07f27cf 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -1,5 +1,5 @@ class Entry < ApplicationRecord - include Monetizable + include Monetizable, Enrichable monetize :amount @@ -34,6 +34,15 @@ class Entry < ApplicationRecord ) } + def classification + amount.negative? ? "income" : "expense" + end + + def lock_saved_attributes! + super + entryable.lock_saved_attributes! + end + def sync_account_later sync_start_date = [ date_previously_was, date ].compact.min unless destroyed? account.sync_later(start_date: sync_start_date) @@ -47,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) @@ -78,6 +83,9 @@ class Entry < ApplicationRecord all.each do |entry| bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present? entry.update! bulk_attributes + + entry.lock_saved_attributes! + entry.entryable.lock!(:tag_ids) if entry.transaction? && entry.transaction.tags.any? end end diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb index bed87613..07793df4 100644 --- a/app/models/entry_search.rb +++ b/app/models/entry_search.rb @@ -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 diff --git a/app/models/entryable.rb b/app/models/entryable.rb index 84ab6c12..2c93786c 100644 --- a/app/models/entryable.rb +++ b/app/models/entryable.rb @@ -8,6 +8,8 @@ module Entryable end included do + include Enrichable + has_one :entry, as: :entryable, touch: true scope :with_entry, -> { joins(:entry) } diff --git a/app/models/family.rb b/app/models/family.rb index 413c969b..caaa4134 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -22,12 +22,13 @@ class Family < ApplicationRecord has_many :entries, through: :accounts has_many :transactions, through: :accounts + has_many :rules, dependent: :destroy has_many :trades, through: :accounts has_many :holdings, through: :accounts has_many :tags, dependent: :destroy has_many :categories, dependent: :destroy - has_many :merchants, dependent: :destroy + has_many :merchants, dependent: :destroy, class_name: "FamilyMerchant" has_many :budgets, dependent: :destroy has_many :budget_categories, through: :budgets @@ -35,6 +36,27 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } + def assigned_merchants + merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq + Merchant.where(id: merchant_ids) + end + + def auto_categorize_transactions_later(transactions) + AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + end + + def auto_categorize_transactions(transaction_ids) + AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize + end + + def auto_detect_transaction_merchants_later(transactions) + AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + end + + def auto_detect_transaction_merchants(transaction_ids) + AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect + end + def balance_sheet @balance_sheet ||= BalanceSheet.new(self) end @@ -46,13 +68,20 @@ class Family < ApplicationRecord def sync_data(sync, start_date: nil) update!(last_synced_at: Time.current) + Rails.logger.info("Syncing accounts for family #{id}") accounts.manual.each do |account| account.sync_later(start_date: start_date, parent_sync: sync) end + Rails.logger.info("Syncing plaid items for family #{id}") plaid_items.each do |plaid_item| plaid_item.sync_later(start_date: start_date, parent_sync: sync) end + + Rails.logger.info("Applying rules for family #{id}") + rules.each do |rule| + rule.apply_later + end end def remove_syncing_notice! @@ -138,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 diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb new file mode 100644 index 00000000..c35aa3b9 --- /dev/null +++ b/app/models/family/auto_categorizer.rb @@ -0,0 +1,88 @@ +class Family::AutoCategorizer + Error = Class.new(StandardError) + + def initialize(family, transaction_ids: []) + @family = family + @transaction_ids = transaction_ids + end + + def auto_categorize + raise Error, "No LLM provider for auto-categorization" unless llm_provider + + if scope.none? + Rails.logger.info("No transactions to auto-categorize for family #{family.id}") + return + else + Rails.logger.info("Auto-categorizing #{scope.count} transactions for family #{family.id}") + end + + result = llm_provider.auto_categorize( + transactions: transactions_input, + user_categories: user_categories_input + ) + + unless result.success? + Rails.logger.error("Failed to auto-categorize transactions for family #{family.id}: #{result.error.message}") + return + end + + scope.each do |transaction| + transaction.lock!(:category_id) + + auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } + + category_id = user_categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) + + if category_id.present? + Family.transaction do + transaction.log_enrichment!( + attribute_name: "category_id", + attribute_value: category_id, + source: "ai", + ) + + transaction.update!(category_id: category_id) + end + end + end + end + + private + attr_reader :family, :transaction_ids + + # For now, OpenAI only, but this should work with any LLM concept provider + def llm_provider + Provider::Registry.get_provider(:openai) + end + + def user_categories_input + family.categories.map do |category| + { + id: category.id, + name: category.name, + is_subcategory: category.subcategory?, + parent_id: category.parent_id, + classification: category.classification + } + end + end + + def transactions_input + scope.map do |transaction| + { + id: transaction.id, + amount: transaction.entry.amount.abs, + classification: transaction.entry.classification, + description: transaction.entry.name, + merchant: transaction.merchant&.name, + hint: transaction.plaid_category_detailed + } + end + end + + def scope + family.transactions.where(id: transaction_ids, category_id: nil) + .enrichable(:category_id) + .includes(:category, :merchant, :entry) + end +end diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb new file mode 100644 index 00000000..4b791e7a --- /dev/null +++ b/app/models/family/auto_merchant_detector.rb @@ -0,0 +1,100 @@ +class Family::AutoMerchantDetector + Error = Class.new(StandardError) + + def initialize(family, transaction_ids: []) + @family = family + @transaction_ids = transaction_ids + end + + def auto_detect + raise "No LLM provider for auto-detecting merchants" unless llm_provider + + if scope.none? + Rails.logger.info("No transactions to auto-detect merchants for family #{family.id}") + return + else + Rails.logger.info("Auto-detecting merchants for #{scope.count} transactions for family #{family.id}") + end + + result = llm_provider.auto_detect_merchants( + transactions: transactions_input, + user_merchants: user_merchants_input + ) + + unless result.success? + Rails.logger.error("Failed to auto-detect merchants for family #{family.id}: #{result.error.message}") + return + end + + scope.each do |transaction| + transaction.lock!(:merchant_id) + + auto_detection = result.data.find { |c| c.transaction_id == transaction.id } + + merchant_id = user_merchants_input.find { |m| m[:name] == auto_detection&.business_name }&.dig(:id) + + if merchant_id.nil? && auto_detection&.business_url.present? && auto_detection&.business_name.present? + ai_provider_merchant = ProviderMerchant.find_or_create_by!( + source: "ai", + name: auto_detection.business_name, + website_url: auto_detection.business_url, + ) do |pm| + pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}" + end + end + + merchant_id = merchant_id || ai_provider_merchant&.id + + if merchant_id.present? + Family.transaction do + transaction.log_enrichment!( + attribute_name: "merchant_id", + attribute_value: merchant_id, + source: "ai", + ) + + transaction.update!(merchant_id: merchant_id) + end + end + end + end + + private + attr_reader :family, :transaction_ids + + # For now, OpenAI only, but this should work with any LLM concept provider + def llm_provider + Provider::Registry.get_provider(:openai) + end + + def default_logo_provider_url + "https://logo.synthfinance.com" + end + + def user_merchants_input + family.merchants.map do |merchant| + { + id: merchant.id, + name: merchant.name + } + end + end + + def transactions_input + scope.map do |transaction| + { + id: transaction.id, + amount: transaction.entry.amount.abs, + classification: transaction.entry.classification, + description: transaction.entry.name, + merchant: transaction.merchant&.name + } + end + end + + def scope + family.transactions.where(id: transaction_ids, merchant_id: nil) + .enrichable(:merchant_id) + .includes(:merchant, :entry) + end +end diff --git a/app/models/family_merchant.rb b/app/models/family_merchant.rb new file mode 100644 index 00000000..eeb88188 --- /dev/null +++ b/app/models/family_merchant.rb @@ -0,0 +1,15 @@ +class FamilyMerchant < Merchant + COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] + + belongs_to :family + + before_validation :set_default_color + + validates :color, presence: true + validates :name, uniqueness: { scope: :family } + + private + def set_default_color + self.color = COLORS.sample + end +end diff --git a/app/models/merchant.rb b/app/models/merchant.rb index 030d9409..4a4257bc 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -1,11 +1,10 @@ class Merchant < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Transaction" - belongs_to :family + TYPES = %w[FamilyMerchant ProviderMerchant].freeze - validates :name, :color, :family, presence: true - validates :name, uniqueness: { scope: :family } + has_many :transactions, dependent: :nullify + + validates :name, presence: true + validates :type, inclusion: { in: TYPES } scope :alphabetically, -> { order(:name) } - - COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4f2b923c..65acf9ae 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -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.merchant_name) + 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,19 +131,19 @@ 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" ] + def find_or_create_merchant(plaid_txn) + unless plaid_txn.merchant_entity_id.present? && plaid_txn.merchant_name.present? + return nil + end - return nil if ignored_categories.include?(plaid_category) - - family.categories.find_or_create_by!(name: plaid_category.titleize) - end - - def get_merchant(plaid_merchant_name) - return nil if plaid_merchant_name.blank? - - family.merchants.find_or_create_by!(name: plaid_merchant_name) + ProviderMerchant.find_or_create_by!( + source: "plaid", + name: plaid_txn.merchant_name, + ) do |m| + m.provider_merchant_id = plaid_txn.merchant_entity_id + m.website_url = plaid_txn.website + m.logo_url = plaid_txn.logo_url + end end def derive_plaid_cash_balance(plaid_balances) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 93a3a13e..baab5104 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -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 = {} diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb index 3d857e4b..f2e8ee8f 100644 --- a/app/models/plaid_item/provided.rb +++ b/app/models/plaid_item/provided.rb @@ -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}" diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index dbdf1eb4..3459ab53 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -1,6 +1,18 @@ module Provider::LlmConcept extend ActiveSupport::Concern + AutoCategorization = Data.define(:transaction_id, :category_name) + + def auto_categorize(transactions) + raise NotImplementedError, "Subclasses must implement #auto_categorize" + end + + AutoDetectedMerchant = Data.define(:transaction_id, :business_name, :business_url) + + def auto_detect_merchants(transactions) + raise NotImplementedError, "Subclasses must implement #auto_detect_merchants" + end + ChatMessage = Data.define(:id, :output_text) ChatStreamChunk = Data.define(:type, :data) ChatResponse = Data.define(:id, :model, :messages, :function_requests) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 70b42056..1268203a 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -14,6 +14,30 @@ class Provider::Openai < Provider MODELS.include?(model) end + def auto_categorize(transactions: [], user_categories: []) + with_provider_response do + raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25 + + AutoCategorizer.new( + client, + transactions: transactions, + user_categories: user_categories + ).auto_categorize + end + end + + def auto_detect_merchants(transactions: [], user_merchants: []) + with_provider_response do + raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 + + AutoMerchantDetector.new( + client, + transactions: transactions, + user_merchants: user_merchants + ).auto_detect_merchants + end + end + def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil) with_provider_response do chat_config = ChatConfig.new( diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb new file mode 100644 index 00000000..6d3e5e8a --- /dev/null +++ b/app/models/provider/openai/auto_categorizer.rb @@ -0,0 +1,120 @@ +class Provider::Openai::AutoCategorizer + def initialize(client, transactions: [], user_categories: []) + @client = client + @transactions = transactions + @user_categories = user_categories + end + + def auto_categorize + response = client.responses.create(parameters: { + model: "gpt-4.1-mini", + input: [ { role: "developer", content: developer_message } ], + text: { + format: { + type: "json_schema", + name: "auto_categorize_personal_finance_transactions", + strict: true, + schema: json_schema + } + }, + instructions: instructions + }) + + Rails.logger.info("Tokens used to auto-categorize transactions: #{response.dig("usage").dig("total_tokens")}") + + build_response(extract_categorizations(response)) + end + + private + attr_reader :client, :transactions, :user_categories + + AutoCategorization = Provider::LlmConcept::AutoCategorization + + def build_response(categorizations) + categorizations.map do |categorization| + AutoCategorization.new( + transaction_id: categorization.dig("transaction_id"), + category_name: normalize_category_name(categorization.dig("category_name")), + ) + end + end + + def normalize_category_name(category_name) + return nil if category_name == "null" + + category_name + end + + def extract_categorizations(response) + response_json = JSON.parse(response.dig("output")[0].dig("content")[0].dig("text")) + response_json.dig("categorizations") + end + + def json_schema + { + type: "object", + properties: { + categorizations: { + type: "array", + description: "An array of auto-categorizations for each transaction", + items: { + type: "object", + properties: { + transaction_id: { + type: "string", + description: "The internal ID of the original transaction", + enum: transactions.map { |t| t[:id] } + }, + category_name: { + type: "string", + description: "The matched category name of the transaction, or null if no match", + enum: [ *user_categories.map { |c| c[:name] }, "null" ] + } + }, + required: [ "transaction_id", "category_name" ], + additionalProperties: false + } + } + }, + required: [ "categorizations" ], + additionalProperties: false + } + end + + def developer_message + <<~MESSAGE.strip_heredoc + Here are the user's available categories in JSON format: + + ```json + #{user_categories.to_json} + ``` + + Use the available categories to auto-categorize the following transactions: + + ```json + #{transactions.to_json} + ``` + MESSAGE + end + + def instructions + <<~INSTRUCTIONS.strip_heredoc + You are an assistant to a consumer personal finance app. You will be provided a list + of the user's transactions and a list of the user's categories. Your job is to auto-categorize + each transaction. + + Closely follow ALL the rules below while auto-categorizing: + + - Return 1 result per transaction + - Correlate each transaction by ID (transaction_id) + - Attempt to match the most specific category possible (i.e. subcategory over parent category) + - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") + - If you don't know the category, return "null" + - You should always favor "null" over false positives + - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. + - Each transaction has varying metadata that can be used to determine the category + - Note: "hint" comes from 3rd party aggregators and typically represents a category name that + may or may not match any of the user-supplied categories + INSTRUCTIONS + end +end diff --git a/app/models/provider/openai/auto_merchant_detector.rb b/app/models/provider/openai/auto_merchant_detector.rb new file mode 100644 index 00000000..f66ca2e7 --- /dev/null +++ b/app/models/provider/openai/auto_merchant_detector.rb @@ -0,0 +1,146 @@ +class Provider::Openai::AutoMerchantDetector + def initialize(client, transactions:, user_merchants:) + @client = client + @transactions = transactions + @user_merchants = user_merchants + end + + def auto_detect_merchants + response = client.responses.create(parameters: { + model: "gpt-4.1-mini", + input: [ { role: "developer", content: developer_message } ], + text: { + format: { + type: "json_schema", + name: "auto_detect_personal_finance_merchants", + strict: true, + schema: json_schema + } + }, + instructions: instructions + }) + + Rails.logger.info("Tokens used to auto-detect merchants: #{response.dig("usage").dig("total_tokens")}") + + build_response(extract_categorizations(response)) + end + + private + attr_reader :client, :transactions, :user_merchants + + AutoDetectedMerchant = Provider::LlmConcept::AutoDetectedMerchant + + def build_response(categorizations) + categorizations.map do |categorization| + AutoDetectedMerchant.new( + transaction_id: categorization.dig("transaction_id"), + business_name: normalize_ai_value(categorization.dig("business_name")), + business_url: normalize_ai_value(categorization.dig("business_url")), + ) + end + end + + def normalize_ai_value(ai_value) + return nil if ai_value == "null" + + ai_value + end + + def extract_categorizations(response) + response_json = JSON.parse(response.dig("output")[0].dig("content")[0].dig("text")) + response_json.dig("merchants") + end + + def json_schema + { + type: "object", + properties: { + merchants: { + type: "array", + description: "An array of auto-detected merchant businesses for each transaction", + items: { + type: "object", + properties: { + transaction_id: { + type: "string", + description: "The internal ID of the original transaction", + enum: transactions.map { |t| t[:id] } + }, + business_name: { + type: [ "string", "null" ], + description: "The detected business name of the transaction, or `null` if uncertain" + }, + business_url: { + type: [ "string", "null" ], + description: "The URL of the detected business, or `null` if uncertain" + } + }, + required: [ "transaction_id", "business_name", "business_url" ], + additionalProperties: false + } + } + }, + required: [ "merchants" ], + additionalProperties: false + } + end + + def developer_message + <<~MESSAGE.strip_heredoc + Here are the user's available merchants in JSON format: + + ```json + #{user_merchants.to_json} + ``` + + Use BOTH your knowledge AND the user-generated merchants to auto-detect the following transactions: + + ```json + #{transactions.to_json} + ``` + + Return "null" if you are not 80%+ confident in your answer. + MESSAGE + end + + def instructions + <<~INSTRUCTIONS.strip_heredoc + You are an assistant to a consumer personal finance app. + + Closely follow ALL the rules below while auto-detecting business names and website URLs: + + - Return 1 result per transaction + - Correlate each transaction by ID (transaction_id) + - Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com") + - User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases + - Be slightly pessimistic. We favor returning "null" over returning a false positive. + - NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner") + + Determining a value: + + - First attempt to determine the name + URL from your knowledge of global businesses + - If no certain match, attempt to match one of the user-provided merchants + - If no match, return "null" + + Example 1 (known business): + + ``` + Transaction name: "Some Amazon purchases" + + Result: + - business_name: "Amazon" + - business_url: "amazon.com" + ``` + + Example 2 (generic business): + + ``` + Transaction name: "local diner" + + Result: + - business_name: null + - business_url: null + ``` + INSTRUCTIONS + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 8ac3b43d..ac5fe0f4 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -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) diff --git a/app/models/provider/plaid/category_alias_matcher.rb b/app/models/provider/plaid/category_alias_matcher.rb new file mode 100644 index 00000000..41ada3a6 --- /dev/null +++ b/app/models/provider/plaid/category_alias_matcher.rb @@ -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 diff --git a/app/models/provider/plaid/category_taxonomy.rb b/app/models/provider/plaid/category_taxonomy.rb new file mode 100644 index 00000000..9766c724 --- /dev/null +++ b/app/models/provider/plaid/category_taxonomy.rb @@ -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 diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index e095d2be..ff75ff49 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -159,38 +159,9 @@ class Provider::Synth < Provider end end - # ================================ - # Transactions - # ================================ - - def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) - with_provider_response do - params = { - description: description, - amount: amount, - date: date, - city: city, - state: state, - country: country - }.compact - - response = client.get("#{base_url}/enrich", params) - - parsed = JSON.parse(response.body) - - TransactionEnrichmentData.new( - name: parsed.dig("merchant"), - icon_url: parsed.dig("icon"), - category: parsed.dig("category") - ) - end - end - private attr_reader :api_key - TransactionEnrichmentData = Data.define(:name, :icon_url, :category) - def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb new file mode 100644 index 00000000..7f677981 --- /dev/null +++ b/app/models/provider_merchant.rb @@ -0,0 +1,6 @@ +class ProviderMerchant < Merchant + enum :source, { plaid: "plaid", synth: "synth", ai: "ai" } + + validates :name, uniqueness: { scope: [ :source ] } + validates :source, presence: true +end diff --git a/app/models/rule.rb b/app/models/rule.rb new file mode 100644 index 00000000..db8a99ae --- /dev/null +++ b/app/models/rule.rb @@ -0,0 +1,90 @@ +class Rule < ApplicationRecord + UnsupportedResourceTypeError = Class.new(StandardError) + + belongs_to :family + has_many :conditions, dependent: :destroy + has_many :actions, dependent: :destroy + + accepts_nested_attributes_for :conditions, allow_destroy: true + accepts_nested_attributes_for :actions, allow_destroy: true + + validates :resource_type, presence: true + validate :no_nested_compound_conditions + + # Every rule must have at least 1 action + validate :min_actions + validate :no_duplicate_actions + + def action_executors + registry.action_executors + end + + def condition_filters + registry.condition_filters + end + + def registry + @registry ||= case resource_type + when "transaction" + Rule::Registry::TransactionResource.new(self) + else + raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" + end + end + + def affected_resource_count + matching_resources_scope.count + end + + def apply(ignore_attribute_locks: false) + actions.each do |action| + action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks) + end + end + + def apply_later(ignore_attribute_locks: false) + RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks) + end + + private + def matching_resources_scope + scope = registry.resource_scope + + # 1. Prepare the query with joins required by conditions + conditions.each do |condition| + scope = condition.prepare(scope) + end + + # 2. Apply the conditions to the query + conditions.each do |condition| + scope = condition.apply(scope) + end + + scope + end + + def min_actions + if actions.reject(&:marked_for_destruction?).empty? + errors.add(:base, "must have at least one action") + end + end + + def no_duplicate_actions + action_types = actions.reject(&:marked_for_destruction?).map(&:action_type) + + errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count + end + + # Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions. + def no_nested_compound_conditions + return true if conditions.none? { |condition| condition.compound? } + + conditions.each do |condition| + if condition.compound? + if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? } + errors.add(:base, "Compound conditions cannot be nested") + end + end + end + end +end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb new file mode 100644 index 00000000..00b66e2c --- /dev/null +++ b/app/models/rule/action.rb @@ -0,0 +1,17 @@ +class Rule::Action < ApplicationRecord + belongs_to :rule + + validates :action_type, presence: true + + def apply(resource_scope, ignore_attribute_locks: false) + executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks) + end + + def options + executor.options + end + + def executor + rule.registry.get_executor!(action_type) + end +end diff --git a/app/models/rule/action_executor.rb b/app/models/rule/action_executor.rb new file mode 100644 index 00000000..807e6cea --- /dev/null +++ b/app/models/rule/action_executor.rb @@ -0,0 +1,43 @@ +class Rule::ActionExecutor + TYPES = [ "select", "function" ] + + def initialize(rule) + @rule = rule + end + + def key + self.class.name.demodulize.underscore + end + + def label + key.humanize + end + + def type + "function" + end + + def options + nil + end + + def execute(scope, value: nil, ignore_attribute_locks: false) + raise NotImplementedError, "Action executor #{self.class.name} must implement #execute" + end + + def as_json + { + type: type, + key: key, + label: label, + options: options + } + end + + private + attr_reader :rule + + def family + rule.family + end +end diff --git a/app/models/rule/action_executor/auto_categorize.rb b/app/models/rule/action_executor/auto_categorize.rb new file mode 100644 index 00000000..72ff324e --- /dev/null +++ b/app/models/rule/action_executor/auto_categorize.rb @@ -0,0 +1,23 @@ +class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor + def label + 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) + enrichable_transactions = transaction_scope.enrichable(:category_id) + + if enrichable_transactions.empty? + Rails.logger.info("No transactions to auto-categorize for #{rule.title} #{rule.id}") + return + end + + enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + Rails.logger.info("Scheduling auto-categorization for batch #{idx + 1} of #{enrichable_transactions.count}") + rule.family.auto_categorize_transactions_later(transactions) + end + end +end diff --git a/app/models/rule/action_executor/auto_detect_merchants.rb b/app/models/rule/action_executor/auto_detect_merchants.rb new file mode 100644 index 00000000..cc523303 --- /dev/null +++ b/app/models/rule/action_executor/auto_detect_merchants.rb @@ -0,0 +1,23 @@ +class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor + def label + 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) + enrichable_transactions = transaction_scope.enrichable(:merchant_id) + + if enrichable_transactions.empty? + Rails.logger.info("No transactions to auto-detect merchants for #{rule.title} #{rule.id}") + return + end + + enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + Rails.logger.info("Scheduling auto-merchant-enrichment for batch #{idx + 1} of #{enrichable_transactions.count}") + rule.family.auto_detect_transaction_merchants_later(transactions) + end + end +end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb new file mode 100644 index 00000000..ef186d96 --- /dev/null +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -0,0 +1,31 @@ +class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor + def type + "select" + end + + def options + family.categories.pluck(:name, :id) + end + + def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + category = family.categories.find_by_id(value) + + scope = transaction_scope + + unless ignore_attribute_locks + scope = scope.enrichable(:category_id) + end + + scope.each do |txn| + Rule.transaction do + txn.log_enrichment!( + attribute_name: "category_id", + attribute_value: category.id, + source: "rule" + ) + + txn.update!(category: category) + end + end + end +end diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb new file mode 100644 index 00000000..4d539496 --- /dev/null +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -0,0 +1,31 @@ +class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor + def type + "select" + end + + def options + family.tags.pluck(:name, :id) + end + + def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + tag = family.tags.find_by_id(value) + + scope = transaction_scope + + unless ignore_attribute_locks + scope = scope.enrichable(:tag_ids) + end + + rows = scope.each do |txn| + Rule.transaction do + txn.log_enrichment!( + attribute_name: "tag_ids", + attribute_value: [ tag.id ], + source: "rule" + ) + + txn.update!(tag_ids: [ tag.id ]) + end + end + end +end diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb new file mode 100644 index 00000000..e15115fe --- /dev/null +++ b/app/models/rule/condition.rb @@ -0,0 +1,62 @@ +class Rule::Condition < ApplicationRecord + belongs_to :rule, optional: -> { where.not(parent_id: nil) } + belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions + + has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent + + validates :condition_type, presence: true + validates :operator, presence: true + validates :value, presence: true, unless: -> { compound? } + + accepts_nested_attributes_for :sub_conditions, allow_destroy: true + + # We don't store rule_id on sub_conditions, so "walk up" to the parent rule + def rule + parent&.rule || super + end + + def compound? + condition_type == "compound" + end + + def apply(scope) + if compound? + build_compound_scope(scope) + else + filter.apply(scope, operator, value) + end + end + + def prepare(scope) + if compound? + sub_conditions.reduce(scope) { |s, sub| sub.prepare(s) } + else + filter.prepare(scope) + end + end + + def options + filter.options + end + + def operators + filter.operators + end + + def filter + rule.registry.get_filter!(condition_type) + end + + private + def build_compound_scope(scope) + if operator == "or" + combined_scope = sub_conditions + .map { |sub| sub.apply(scope) } + .reduce { |acc, s| acc.or(s) } + + combined_scope || scope + else + sub_conditions.reduce(scope) { |s, sub| sub.apply(s) } + end + end +end diff --git a/app/models/rule/condition_filter.rb b/app/models/rule/condition_filter.rb new file mode 100644 index 00000000..86d40ea2 --- /dev/null +++ b/app/models/rule/condition_filter.rb @@ -0,0 +1,87 @@ +class Rule::ConditionFilter + UnsupportedOperatorError = Class.new(StandardError) + + TYPES = [ "text", "number", "select" ] + + OPERATORS_MAP = { + "text" => [ [ "Contains", "like" ], [ "Equal to", "=" ] ], + "number" => [ [ "Greater than", ">" ], [ "Greater or equal to", ">=" ], [ "Less than", "<" ], [ "Less than or equal to", "<=" ], [ "Is equal to", "=" ] ], + "select" => [ [ "Equal to", "=" ] ] + } + + def initialize(rule) + @rule = rule + end + + def type + "text" + end + + def number_step + family_currency = Money::Currency.new(family.currency) + family_currency.step + end + + def key + self.class.name.demodulize.underscore + end + + def label + key.humanize + end + + def options + nil + end + + def operators + OPERATORS_MAP.dig(type) + end + + # Matchers can prepare the scope with joins by implementing this method + def prepare(scope) + scope + end + + # Applies the condition to the scope + def apply(scope, operator, value) + raise NotImplementedError, "Condition #{self.class.name} must implement #apply" + end + + def as_json + { + type: type, + key: key, + label: label, + operators: operators, + options: options, + number_step: number_step + } + end + + private + attr_reader :rule + + def family + rule.family + end + + def build_sanitized_where_condition(field, operator, value) + sanitized_value = operator == "like" ? "%#{ActiveRecord::Base.sanitize_sql_like(value)}%" : value + + ActiveRecord::Base.sanitize_sql_for_conditions([ + "#{field} #{sanitize_operator(operator)} ?", + sanitized_value + ]) + end + + def sanitize_operator(operator) + raise UnsupportedOperatorError, "Unsupported operator: #{operator} for type: #{type}" unless operators.map(&:last).include?(operator) + + if operator == "like" + "ILIKE" + else + operator + end + end +end diff --git a/app/models/rule/condition_filter/transaction_amount.rb b/app/models/rule/condition_filter/transaction_amount.rb new file mode 100644 index 00000000..5be1a9fb --- /dev/null +++ b/app/models/rule/condition_filter/transaction_amount.rb @@ -0,0 +1,14 @@ +class Rule::ConditionFilter::TransactionAmount < Rule::ConditionFilter + def type + "number" + end + + def prepare(scope) + scope.with_entry + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("ABS(entries.amount)", operator, value.to_d) + scope.where(expression) + end +end diff --git a/app/models/rule/condition_filter/transaction_merchant.rb b/app/models/rule/condition_filter/transaction_merchant.rb new file mode 100644 index 00000000..db152226 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_merchant.rb @@ -0,0 +1,18 @@ +class Rule::ConditionFilter::TransactionMerchant < Rule::ConditionFilter + def type + "select" + end + + def options + family.assigned_merchants.pluck(:name, :id) + end + + def prepare(scope) + scope.left_joins(:merchant) + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("merchants.id", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/condition_filter/transaction_name.rb b/app/models/rule/condition_filter/transaction_name.rb new file mode 100644 index 00000000..c1b3baf7 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_name.rb @@ -0,0 +1,10 @@ +class Rule::ConditionFilter::TransactionName < Rule::ConditionFilter + def prepare(scope) + scope.with_entry + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("entries.name", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/registry.rb b/app/models/rule/registry.rb new file mode 100644 index 00000000..4c3f80cf --- /dev/null +++ b/app/models/rule/registry.rb @@ -0,0 +1,46 @@ +class Rule::Registry + UnsupportedActionError = Class.new(StandardError) + UnsupportedConditionError = Class.new(StandardError) + + def initialize(rule) + @rule = rule + end + + def resource_scope + raise NotImplementedError, "#{self.class.name} must implement #resource_scope" + end + + def condition_filters + [] + end + + def action_executors + [] + end + + def get_filter!(key) + filter = condition_filters.find { |filter| filter.key == key } + raise UnsupportedConditionError, "Unsupported condition type: #{key}" unless filter + filter + end + + def get_executor!(key) + executor = action_executors.find { |executor| executor.key == key } + raise UnsupportedActionError, "Unsupported action type: #{key}" unless executor + executor + end + + def as_json + { + filters: condition_filters.map(&:as_json), + executors: action_executors.map(&:as_json) + } + end + + private + attr_reader :rule + + def family + rule.family + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb new file mode 100644 index 00000000..1bcdf8a7 --- /dev/null +++ b/app/models/rule/registry/transaction_resource.rb @@ -0,0 +1,32 @@ +class Rule::Registry::TransactionResource < Rule::Registry + def resource_scope + family.transactions.active.with_entry.where(entry: { date: rule.effective_date.. }) + end + + def condition_filters + [ + Rule::ConditionFilter::TransactionName.new(rule), + Rule::ConditionFilter::TransactionAmount.new(rule), + Rule::ConditionFilter::TransactionMerchant.new(rule) + ] + end + + def action_executors + enabled_executors = [ + Rule::ActionExecutor::SetTransactionCategory.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 diff --git a/app/models/sync.rb b/app/models/sync.rb index 75718d0b..04141570 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -23,19 +23,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 diff --git a/app/models/trade_builder.rb b/app/models/trade_builder.rb index 9b7e0471..4f0019b9 100644 --- a/app/models/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -15,6 +15,21 @@ class TradeBuilder buildable.save end + def lock_saved_attributes! + if buildable.is_a?(Transfer) + buildable.inflow_transaction.entry.lock_saved_attributes! + buildable.outflow_transaction.entry.lock_saved_attributes! + else + buildable.lock_saved_attributes! + end + end + + def entryable + return nil if buildable.is_a?(Transfer) + + buildable.entryable + end + def errors buildable.errors end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index fb5fb0df..d2a8abdc 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,5 @@ class Transaction < ApplicationRecord - include Entryable, Transferable, Provided + include Entryable, Transferable, Ruleable belongs_to :category, optional: true belongs_to :merchant, optional: true @@ -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 diff --git a/app/models/transaction/provided.rb b/app/models/transaction/provided.rb deleted file mode 100644 index b4210e0a..00000000 --- a/app/models/transaction/provided.rb +++ /dev/null @@ -1,20 +0,0 @@ -module Transaction::Provided - extend ActiveSupport::Concern - - def fetch_enrichment_info - return nil unless provider - - response = provider.enrich_transaction( - entry.name, - amount: entry.amount, - date: entry.date - ) - - response.data - end - - private - def provider - Provider::Registry.get_provider(:synth) - end -end diff --git a/app/models/transaction/ruleable.rb b/app/models/transaction/ruleable.rb new file mode 100644 index 00000000..aacd1b92 --- /dev/null +++ b/app/models/transaction/ruleable.rb @@ -0,0 +1,17 @@ +module Transaction::Ruleable + extend ActiveSupport::Concern + + def eligible_for_category_rule? + rules.joins(:actions).where( + actions: { + action_type: "set_transaction_category", + value: category_id + } + ).empty? + end + + private + def rules + entry.account.family.rules + end +end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index d681d581..601966bb 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -29,7 +29,6 @@ class Transfer < ApplicationRecord currency: converted_amount.currency.iso_code, date: date, name: "Transfer from #{from_account.name}", - entryable: Transaction.new ) ), outflow_transaction: Transaction.new( @@ -38,7 +37,6 @@ class Transfer < ApplicationRecord currency: from_account.currency, date: date, name: "Transfer to #{to_account.name}", - entryable: Transaction.new ) ), status: "confirmed" diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 80ad5a07..afbd6fb3 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -32,14 +32,7 @@

<% unless account.scheduled_for_deletion? %> - <%= form_with model: account, - namespace: account.id, - data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> -
- <%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %> - <%= form.label :is_active, " ".html_safe, class: "switch" %> -
- <% end %> + <%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %> <% end %> diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb index 8755d0b8..5f489f94 100644 --- a/app/views/budget_categories/_no_categories.html.erb +++ b/app/views/budget_categories/_no_categories.html.erb @@ -6,7 +6,7 @@

- <%= 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") %> diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb index d3e25261..f04e1e96 100644 --- a/app/views/categories/_category.html.erb +++ b/app/views/categories/_category.html.erb @@ -10,20 +10,18 @@
<%= contextual_menu do %> -
- <%= 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" %> - <%= t(".delete") %> - <% 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" %> + <%= t(".delete") %> <% end %> -
+ <% else %> + <%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %> + <% end %> <% end %>
diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb index e577bb5e..43daea2d 100644 --- a/app/views/categories/edit.html.erb +++ b/app/views/categories/edit.html.erb @@ -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 %> diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index 21ecc95a..fec33344 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -1,11 +1,20 @@ -

<%= t(".categories") %>

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

<%= t(".new") %>

- <% end %> +
+ <%= 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" %> +

<%= t(".new") %>

+ <% end %> +
diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb index 2d100b49..6478d94d 100644 --- a/app/views/categories/new.html.erb +++ b/app/views/categories/new.html.erb @@ -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 %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb index bf10a596..8eb610b7 100644 --- a/app/views/chats/index.html.erb +++ b/app/views/chats/index.html.erb @@ -7,7 +7,7 @@ <% end %> -
+

Chats

<% if @chats.any? %> @@ -22,7 +22,7 @@

No chats yet

Start a new conversation with the AI assistant

-
+
<%= render "messages/chat_form", chat: nil %>
<% end %> diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index 28f9b8c1..341eca01 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -28,7 +28,7 @@ <% end %>
-
+
<%= render "messages/chat_form", chat: @chat %>
diff --git a/app/views/family_merchants/_family_merchant.html.erb b/app/views/family_merchants/_family_merchant.html.erb new file mode 100644 index 00000000..ec1cc1fd --- /dev/null +++ b/app/views/family_merchants/_family_merchant.html.erb @@ -0,0 +1,31 @@ +<%# locals: (family_merchant:) %> + +
+
+ <% if family_merchant.logo_url %> +
+ <%= image_tag family_merchant.logo_url, class: "w-8 h-8 rounded-full" %> +
+ <% else %> + <%= render partial: "shared/color_avatar", locals: { name: family_merchant.name, color: family_merchant.color } %> + <% end %> + +

+ <%= family_merchant.name %> +

+
+
+ <%= contextual_menu do %> + <%= contextual_menu_modal_action_item t(".edit"), edit_family_merchant_path(family_merchant), icon: "pencil", turbo_frame: "modal" %> + + <%= contextual_menu_destructive_item "Delete", + family_merchant_path(family_merchant), + turbo_frame: "_top", + turbo_confirm: family_merchant.transactions.any? ? { + title: "Delete #{family_merchant.name}?", + body: "This will remove this merchant from all transactions it has been assigned to.", + accept: "Delete" + } : nil %> + <% end %> +
+
diff --git a/app/views/merchants/_form.html.erb b/app/views/family_merchants/_form.html.erb similarity index 77% rename from app/views/merchants/_form.html.erb rename to app/views/family_merchants/_form.html.erb index 93efecb9..baaec60a 100644 --- a/app/views/merchants/_form.html.erb +++ b/app/views/family_merchants/_form.html.erb @@ -1,11 +1,14 @@
- <%= styled_form_with model: @merchant, class: "space-y-4", data: { turbo_frame: :_top } do |f| %> + <%= styled_form_with model: @merchant, class: "space-y-4" do |f| %>
-
+ <% if @merchant.errors.any? %> + <%= render "shared/form_errors", model: @merchant %> + <% end %> +
<%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
- <% Merchant::COLORS.each do |color| %> + <% FamilyMerchant::COLORS.each do |color| %>
@@ -26,7 +26,7 @@

<%= t(".empty") %>

- <%= link_to new_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> + <%= link_to new_family_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new") %> <% end %> diff --git a/app/views/merchants/new.html.erb b/app/views/family_merchants/new.html.erb similarity index 100% rename from app/views/merchants/new.html.erb rename to app/views/family_merchants/new.html.erb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b61c6588..846a656c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,56 +5,58 @@ data-controller="sidebar" data-sidebar-user-id-value="<%= Current.user.id %>" data-sidebar-config-value="<%= sidebar_config.to_json %>"> + + + <% unless controller_name == 'chats' %> - <% end %>
@@ -110,7 +112,7 @@ <% end %>
-
@@ -87,72 +90,63 @@
diff --git a/app/views/settings/preferences/_data_enrichment_settings.html.erb b/app/views/settings/preferences/_data_enrichment_settings.html.erb deleted file mode 100644 index bce47776..00000000 --- a/app/views/settings/preferences/_data_enrichment_settings.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%# locals: (user:) %> - -
-
-
-

<%= t(".title") %>

-

<%= t(".description") %>

- <% if self_hosted? %> -

<%= t(".self_host_disclaimer") %>

- <% end %> -
- - <%= styled_form_with model: user, - data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> -
- <%= form.hidden_field :redirect_to, value: "preferences" %> - <%= form.fields_for :family do |family_form| %> - <%= family_form.check_box :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> - <%= family_form.label :data_enrichment_enabled, " ".html_safe, class: "switch" %> - <% end %> -
- <% end %> -
-
diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index e91567fd..99857aba 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -41,10 +41,6 @@
<% end %> -<%= settings_section title: t(".data"), subtitle: t(".data_subtitle") do %> - <%= render "settings/preferences/data_enrichment_settings", user: @user %> -<% end %> - <%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<%= styled_form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/shared/_confirm_modal.html.erb b/app/views/shared/_confirm_modal.html.erb index eb1ca925..52410d14 100644 --- a/app/views/shared/_confirm_modal.html.erb +++ b/app/views/shared/_confirm_modal.html.erb @@ -11,7 +11,6 @@ <%= t(".body_html") %>
- - + diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb index ce7c0394..3fdcb70a 100644 --- a/app/views/shared/_modal.html.erb +++ b/app/views/shared/_modal.html.erb @@ -1,8 +1,19 @@ -<%# locals: (content:, classes:) -%> +<%# locals: (content:, reload_on_close:, overflow_visible: false) -%> + <%= turbo_frame_tag "modal" do %> - + <%= tag.dialog( + class: class_names( + "focus:outline-none md:m-auto bg-container rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto 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 %>
<%= content %>
-
+ <% end %> <% end %> diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb index 72c39039..cbf821c8 100644 --- a/app/views/shared/_modal_form.html.erb +++ b/app/views/shared/_modal_form.html.erb @@ -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 %>
diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb deleted file mode 100644 index 17393390..00000000 --- a/app/views/shared/_notification.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -<%# locals: (message:, type: "notice", **_opts) %> - -<% type = type.to_sym %> -<% action = "animationend->element-removal#remove" if type == :notice %> - -<%= tag.div class: "flex gap-3 rounded-lg border bg-container p-4 group max-w-80 shadow-xs border-alpha-black-25 mx-auto md:mx-0", - data: { - controller: "element-removal", - action: action - } do %> - -
- <% case type %> - <% when :notice %> -
- <%= lucide_icon "check", class: "w-3 h-3" %> -
- <% when :alert %> -
- <%= lucide_icon "x", class: "w-3 h-3" %> -
- <% end %> -
- - <%= tag.p message, class: "text-primary text-sm font-medium" %> - -
- <% if type.to_sym == :notice %> -
- - - - -
- <%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-container text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %> -
-
- <% elsif type.to_sym == :alert %> - <%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-secondary hover:text-gray-600 cursor-pointer" %> - <% end %> -
-<% end %> diff --git a/app/views/shared/_syncing_notice.html.erb b/app/views/shared/_syncing_notice.html.erb deleted file mode 100644 index 07e163bf..00000000 --- a/app/views/shared/_syncing_notice.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-container p-4 group w-full shadow-xs border-alpha-black-25" do %> -
- <%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %> -
- - <%= tag.p t(".syncing"), class: "text-primary text-sm font-medium" %> -<% end %> diff --git a/app/views/shared/_toggle_form.html.erb b/app/views/shared/_toggle_form.html.erb new file mode 100644 index 00000000..a5d1ed25 --- /dev/null +++ b/app/views/shared/_toggle_form.html.erb @@ -0,0 +1,11 @@ +<%# locals: (model:, attribute:, turbo_frame: nil) %> + +<%= form_with model: model, + namespace: model.id, + class: "flex items-center", + data: { controller: "auto-submit-form", turbo_frame: turbo_frame } do |form| %> +
+ <%= form.check_box attribute, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %> + <%= form.label attribute, " ".html_safe, class: "switch" %> +
+<% end %> diff --git a/app/views/shared/notifications/_alert.html.erb b/app/views/shared/notifications/_alert.html.erb new file mode 100644 index 00000000..3b513aa6 --- /dev/null +++ b/app/views/shared/notifications/_alert.html.erb @@ -0,0 +1,16 @@ +<%# locals: (message:) %> + +<%= tag.div class: "flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-lg", + data: { controller: "element-removal" } do %> +
+
+ <%= lucide_icon "x", class: "w-3 h-3" %> +
+
+ + <%= tag.p message, class: "text-primary text-sm font-medium" %> + +
+ <%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-secondary hover:text-gray-600 cursor-pointer" %> +
+<% end %> diff --git a/app/views/shared/notifications/_cta.html.erb b/app/views/shared/notifications/_cta.html.erb new file mode 100644 index 00000000..57e408c6 --- /dev/null +++ b/app/views/shared/notifications/_cta.html.erb @@ -0,0 +1,20 @@ +<%# locals: (message:, description:) %> + +
+ <%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs", data: { controller: "element-removal" } do %> +
+
+ <%= lucide_icon "check", class: "w-3 h-3" %> +
+
+ +
+
+ <%= tag.p message, class: "text-primary text-sm font-medium" %> + <%= tag.p description, class: "text-secondary text-sm" %> +
+ + <%= yield %> +
+ <% end %> +
diff --git a/app/views/shared/notifications/_loading.html.erb b/app/views/shared/notifications/_loading.html.erb new file mode 100644 index 00000000..e654171a --- /dev/null +++ b/app/views/shared/notifications/_loading.html.erb @@ -0,0 +1,9 @@ +<%# locals: (message:, id: nil) %> + +<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-white p-4 group w-full shadow-border-xs" do %> +
+ <%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %> +
+ + <%= tag.p message, class: "text-primary text-sm font-medium" %> +<% end %> diff --git a/app/views/shared/notifications/_notice.html.erb b/app/views/shared/notifications/_notice.html.erb new file mode 100644 index 00000000..fcb62e09 --- /dev/null +++ b/app/views/shared/notifications/_notice.html.erb @@ -0,0 +1,48 @@ +<%# locals: (message:, description: nil, cta: nil) %> + +<%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs", + data: { + controller: "element-removal", + action: "animationend->element-removal#remove" + } do %> + +
+
+ <%= lucide_icon "check", class: "w-3 h-3" %> +
+
+ +
+
+ <%= tag.p message, class: "text-primary text-sm font-medium" %> + + <% if description %> + <%= tag.p description, class: "text-secondary text-sm" %> + <% end %> +
+ + <% if cta %> + <%= tag.div class:"flex gap-2 justify-end" do %> + <%= tag.button cta[:decline][:label], class: "btn btn--secondary", data: { action: "click->element-removal#remove" } %> + <%= tag.a cta[:accept][:label], href: cta[:accept][:href], class: "btn btn--primary" %> + <% end %> + <% end %> +
+ +
+
+ <% unless cta %> + + + + + <% end %> +
+
+ + <% unless cta %> +
+ <%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %> +
+ <% end %> +<% end %> diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 9d8bed5f..eb558dd3 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -13,11 +13,11 @@
<%= tag.div class: ["flex items-center gap-2"] do %>
- <%= entry.display_name.first.upcase %> + <%= entry.name.first.upcase %>
- <%= 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" %> diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 38cb890b..565f8c8b 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -1,4 +1,4 @@ -<%= drawer(reload_on_close: true) do %> +<%= drawer do %> <%= render "trades/header", entry: @entry %> <% trade = @entry.trade %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index 5b3e75b8..410429ee 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -20,4 +20,4 @@ <%= I18n.l(entry.date, format: :long) %> -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index d2fb2423..dde46135 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -21,13 +21,13 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> - <% if transaction.merchant&.icon_url %> - <%= image_tag transaction.merchant.icon_url, + <% if transaction.merchant&.logo_url.present? %> + <%= image_tag transaction.merchant.logo_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> <% else %> <%= render "shared/circle_logo", - name: entry.display_name, + name: entry.name, size: "sm" %> <% end %> @@ -35,7 +35,7 @@
<%= 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", diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index c0f32270..7ed329aa 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -4,9 +4,14 @@
<%= contextual_menu do %> + <% if Rails.env.development? %> + <%= button_to "Dev only: Sync all", sync_all_accounts_path, class: "btn btn--ghost w-full" %> + <% end %> + <%= contextual_menu_item "New rule", url: new_rule_path(resource_type: "transaction"), icon: "plus", turbo_frame: :modal %> + <%= contextual_menu_item "Edit rules", url: rules_path, icon: "git-branch", turbo_frame: :_top %> <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_merchants"), family_merchants_path, icon: "store", turbo_frame: :_top %> <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> <%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "download", turbo_frame: "modal", class_name: "md:!hidden" %> <% end %> diff --git a/app/views/transactions/searches/filters/_merchant_filter.html.erb b/app/views/transactions/searches/filters/_merchant_filter.html.erb index 9910134c..3fb044b5 100644 --- a/app/views/transactions/searches/filters/_merchant_filter.html.erb +++ b/app/views/transactions/searches/filters/_merchant_filter.html.erb @@ -5,7 +5,7 @@ <%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
- <% Current.family.merchants.alphabetically.each do |merchant| %> + <% Current.family.assigned_merchants.alphabetically.each do |merchant| %>
<%= form.check_box :merchants, { diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index bf078dd3..7306ffc0 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -1,4 +1,4 @@ -<%= drawer(reload_on_close: true) do %> +<%= drawer do %> <%= render "transactions/header", entry: @entry %>
@@ -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" %> @@ -66,7 +66,7 @@ <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :merchant_id, - Current.family.merchants.alphabetically, + Current.family.assigned_merchants.alphabetically, :id, :name, { include_blank: t(".none"), label: t(".merchant_label"), @@ -81,7 +81,7 @@ label: t(".tags_label"), container_class: "h-40" }, - { "data-auto-submit-form-target": "auto" } %> + { "data-auto-submit-form-target": "auto" } %> <% end %> <% end %> diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb index 09694b34..1308a927 100644 --- a/app/views/valuations/_valuation.html.erb +++ b/app/views/valuations/_valuation.html.erb @@ -19,7 +19,7 @@ <% end %>
- <%= 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" %> diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb index 94d01569..9a21b2e7 100644 --- a/app/views/valuations/show.html.erb +++ b/app/views/valuations/show.html.erb @@ -1,6 +1,6 @@ <% entry, account = @entry, @entry.account %> -<%= drawer(reload_on_close: true) do %> +<%= drawer do %> <%= render "valuations/header", entry: %>
diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 1b3ee826..9f089fb7 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -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 diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml index 3f31dd59..fab034ce 100644 --- a/config/locales/views/merchants/en.yml +++ b/config/locales/views/merchants/en.yml @@ -1,6 +1,6 @@ --- en: - merchants: + family_merchants: create: error: 'Error creating merchant: %{error}' success: New merchant created successfully diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index c4b0f553..f5d01c0e 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -7,16 +7,9 @@ en: subscription_subtitle: Update your subscription and billing details subscription_title: Manage subscription preferences: - data_enrichment_settings: - description: Let Maybe auto-categorize, name, and add merchant data to your - transactions on each sync. All enrichment is done in English. - self_host_disclaimer: This will incur Synth API credits. - title: Transaction enrichment (English only) show: country: Country currency: Currency - data: Data enrichment - data_subtitle: Enable data enrichment for your accounts date_format: Date format general_subtitle: Configure your preferences general_title: General diff --git a/config/routes.rb b/config/routes.rb index 35f1b2e3..501ffb99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ Rails.application.routes.draw do resources :users, only: %i[update destroy] do delete :reset, on: :member + patch :rule_prompt_settings, on: :member end resource :onboarding, only: :show do @@ -67,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 @@ -75,7 +77,7 @@ Rails.application.routes.draw do resources :budget_categories, only: %i[index show update] end - resources :merchants, only: %i[index new create edit update destroy] + resources :family_merchants, only: %i[index new create edit update destroy] resources :transfers, only: %i[new create destroy show update] @@ -135,6 +137,17 @@ Rails.application.routes.draw do end end + resources :rules, except: :show do + member do + get :confirm + post :apply + end + + collection do + delete :destroy_all + end + end + # Convenience routes for polymorphic paths # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123 direct :account do |model, options| diff --git a/db/migrate/20250416235317_add_rules_engine.rb b/db/migrate/20250416235317_add_rules_engine.rb new file mode 100644 index 00000000..960a479e --- /dev/null +++ b/db/migrate/20250416235317_add_rules_engine.rb @@ -0,0 +1,33 @@ +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 + + t.string :resource_type, null: false + t.date :effective_date + t.boolean :active, null: false, default: false + t.timestamps + end + + create_table :rule_conditions, id: :uuid do |t| + t.references :rule, foreign_key: true, type: :uuid + t.references :parent, foreign_key: { to_table: :rule_conditions }, type: :uuid + + t.string :condition_type, null: false + t.string :operator, null: false + t.string :value + t.timestamps + end + + create_table :rule_actions, id: :uuid do |t| + t.references :rule, null: false, foreign_key: true, type: :uuid + + t.string :action_type, null: false + 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 diff --git a/db/migrate/20250416235420_add_data_enrichments.rb b/db/migrate/20250416235420_add_data_enrichments.rb new file mode 100644 index 00000000..1997461c --- /dev/null +++ b/db/migrate/20250416235420_add_data_enrichments.rb @@ -0,0 +1,33 @@ +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 + t.string :source + t.string :attribute_name + t.jsonb :value + t.jsonb :metadata + + t.timestamps + end + + add_index :data_enrichments, [ :enrichable_id, :enrichable_type, :source, :attribute_name ], unique: true + + # Entries + add_column :entries, :locked_attributes, :jsonb, default: {} + add_column :transactions, :locked_attributes, :jsonb, default: {} + add_column :trades, :locked_attributes, :jsonb, default: {} + add_column :valuations, :locked_attributes, :jsonb, default: {} + + # Accounts + add_column :accounts, :locked_attributes, :jsonb, default: {} + add_column :depositories, :locked_attributes, :jsonb, default: {} + add_column :investments, :locked_attributes, :jsonb, default: {} + add_column :cryptos, :locked_attributes, :jsonb, default: {} + add_column :properties, :locked_attributes, :jsonb, default: {} + add_column :vehicles, :locked_attributes, :jsonb, default: {} + add_column :other_assets, :locked_attributes, :jsonb, default: {} + add_column :credit_cards, :locked_attributes, :jsonb, default: {} + add_column :loans, :locked_attributes, :jsonb, default: {} + add_column :other_liabilities, :locked_attributes, :jsonb, default: {} + end +end diff --git a/db/migrate/20250416235758_merchant_and_category_enrichment.rb b/db/migrate/20250416235758_merchant_and_category_enrichment.rb new file mode 100644 index 00000000..2c25eeb3 --- /dev/null +++ b/db/migrate/20250416235758_merchant_and_category_enrichment.rb @@ -0,0 +1,34 @@ +class MerchantAndCategoryEnrichment < ActiveRecord::Migration[7.2] + def change + change_column_null :merchants, :family_id, true + change_column_null :merchants, :color, true + change_column_default :merchants, :color, from: "#e99537", to: nil + remove_column :merchants, :enriched_at, :datetime + rename_column :merchants, :icon_url, :logo_url + + add_column :merchants, :website_url, :string + add_column :merchants, :type, :string + add_index :merchants, :type + + reversible do |dir| + dir.up do + # All of our existing merchants are family-generated right now + Merchant.update_all(type: "FamilyMerchant") + end + end + + change_column_null :merchants, :type, false + + 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 ], 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 diff --git a/db/schema.rb b/db/schema.rb index 280aae75..adb03c7d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_13_141446) 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" @@ -36,6 +36,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.boolean "scheduled_for_deletion", default: false t.datetime "last_synced_at" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "locked_attributes", default: {} t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -156,16 +157,32 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.decimal "apr", precision: 10, scale: 2 t.date "expiration_date" t.decimal "annual_fee", precision: 10, scale: 2 + t.jsonb "locked_attributes", default: {} end create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} + end + + create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "enrichable_type", null: false + t.uuid "enrichable_id", null: false + t.string "source" + t.string "attribute_name" + t.jsonb "value" + t.jsonb "metadata" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true + t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} end create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -182,8 +199,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) 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" end @@ -324,6 +340,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} end create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -357,17 +374,24 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.decimal "interest_rate", precision: 10, scale: 3 t.integer "term_months" t.decimal "initial_balance", precision: 19, scale: 4 + t.jsonb "locked_attributes", default: {} end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false - t.string "color", default: "#e99537", null: false - t.uuid "family_id", null: false + t.string "color" + t.uuid "family_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "icon_url" - t.datetime "enriched_at" + t.string "logo_url" + t.string "website_url" + t.string "type", null: false + t.string "source" + t.string "provider_merchant_id" + t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" t.index ["family_id"], name: "index_merchants_on_family_id" + t.index ["source", "name"], name: "index_merchants_on_source_and_name", unique: true, where: "((type)::text = 'ProviderMerchant'::text)" + t.index ["type"], name: "index_merchants_on_type" end create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -387,11 +411,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} end create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} end create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -435,6 +461,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.integer "year_built" t.integer "area_value" t.string "area_unit" + t.jsonb "locked_attributes", default: {} end create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -447,6 +474,37 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id" end + create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id", null: false + t.string "action_type", null: false + t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rule_id"], name: "index_rule_actions_on_rule_id" + end + + create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id" + t.uuid "parent_id" + t.string "condition_type", null: false + t.string "operator", null: false + t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" + t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" + end + + create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "resource_type", null: false + t.date "effective_date" + t.boolean "active", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_rules_on_family_id" + end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker", null: false t.string "name" @@ -570,6 +628,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "currency" + t.jsonb "locked_attributes", default: {} t.index ["security_id"], name: "index_trades_on_security_id" end @@ -578,6 +637,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.datetime "updated_at", null: false 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 @@ -615,6 +677,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do 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.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" @@ -624,6 +688,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} end create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -634,6 +699,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.string "mileage_unit" t.string "make" t.string "model" + t.jsonb "locked_attributes", default: {} end add_foreign_key "accounts", "families" @@ -664,6 +730,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do add_foreign_key "plaid_items", "families" add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id" add_foreign_key "rejected_transfers", "transactions", column: "outflow_transaction_id" + add_foreign_key "rule_actions", "rules" + add_foreign_key "rule_conditions", "rule_conditions", column: "parent_id" + add_foreign_key "rule_conditions", "rules" + add_foreign_key "rules", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 5ec08b41..2cbb46f5 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -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 diff --git a/test/controllers/family_merchants_controller_test.rb b/test/controllers/family_merchants_controller_test.rb new file mode 100644 index 00000000..eda482a1 --- /dev/null +++ b/test/controllers/family_merchants_controller_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class FamilyMerchantsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @merchant = merchants(:netflix) + end + + test "index" do + get family_merchants_path + assert_response :success + end + + test "new" do + get new_family_merchant_path + assert_response :success + end + + test "should create merchant" do + assert_difference("FamilyMerchant.count") do + post family_merchants_url, params: { family_merchant: { name: "new merchant", color: "#000000" } } + end + + assert_redirected_to family_merchants_path + end + + test "should update merchant" do + patch family_merchant_url(@merchant), params: { family_merchant: { name: "new name", color: "#000000" } } + assert_redirected_to family_merchants_path + end + + test "should destroy merchant" do + assert_difference("FamilyMerchant.count", -1) do + delete family_merchant_url(@merchant) + end + + assert_redirected_to family_merchants_path + end +end diff --git a/test/controllers/merchants_controller_test.rb b/test/controllers/merchants_controller_test.rb deleted file mode 100644 index 4f84bb0d..00000000 --- a/test/controllers/merchants_controller_test.rb +++ /dev/null @@ -1,39 +0,0 @@ -require "test_helper" - -class MerchantsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in @user = users(:family_admin) - @merchant = merchants(:netflix) - end - - test "index" do - get merchants_path - assert_response :success - end - - test "new" do - get new_merchant_path - assert_response :success - end - - test "should create merchant" do - assert_difference("Merchant.count") do - post merchants_url, params: { merchant: { name: "new merchant", color: "#000000" } } - end - - assert_redirected_to merchants_path - end - - test "should update merchant" do - patch merchant_url(@merchant), params: { merchant: { name: "new name", color: "#000000" } } - assert_redirected_to merchants_path - end - - test "should destroy merchant" do - assert_difference("Merchant.count", -1) do - delete merchant_url(@merchant) - end - - assert_redirected_to merchants_path - end -end diff --git a/test/controllers/rules_controller_test.rb b/test/controllers/rules_controller_test.rb new file mode 100644 index 00000000..cb5f8a0d --- /dev/null +++ b/test/controllers/rules_controller_test.rb @@ -0,0 +1,165 @@ +require "test_helper" + +class RulesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + end + + test "should get new" do + get new_rule_url(resource_type: "transaction") + assert_response :success + end + + test "should get edit" do + get edit_rule_url(rules(:one)) + assert_response :success + end + + # "Set all transactions with a name like 'starbucks' and an amount between 20 and 40 to the 'food and drink' category" + test "creates rule with nested conditions" do + post rules_url, params: { + rule: { + effective_date: 30.days.ago.to_date, + resource_type: "transaction", + conditions_attributes: { + "0" => { + condition_type: "transaction_name", + operator: "like", + value: "starbucks" + }, + "1" => { + condition_type: "compound", + operator: "and", + sub_conditions_attributes: { + "0" => { + condition_type: "transaction_amount", + operator: ">", + value: 20 + }, + "1" => { + condition_type: "transaction_amount", + operator: "<", + value: 40 + } + } + } + }, + actions_attributes: { + "0" => { + action_type: "set_transaction_category", + value: categories(:food_and_drink).id + } + } + } + } + + rule = @user.family.rules.order("created_at DESC").first + + # Rule + assert_equal "transaction", rule.resource_type + assert_not rule.active # Not active by default + assert_equal 30.days.ago.to_date, rule.effective_date + + # Conditions assertions + assert_equal 2, rule.conditions.count + compound_condition = rule.conditions.find { |condition| condition.condition_type == "compound" } + assert_equal "compound", compound_condition.condition_type + assert_equal 2, compound_condition.sub_conditions.count + + # Actions assertions + assert_equal 1, rule.actions.count + assert_equal "set_transaction_category", rule.actions.first.action_type + assert_equal categories(:food_and_drink).id, rule.actions.first.value + + assert_redirected_to confirm_rule_url(rule) + end + + test "can update rule" do + rule = rules(:one) + + assert_difference -> { Rule.count } => 0, + -> { Rule::Condition.count } => 1, + -> { Rule::Action.count } => 1 do + patch rule_url(rule), params: { + rule: { + active: false, + conditions_attributes: { + "0" => { + id: rule.conditions.first.id, + value: "new_value" + }, + "1" => { + condition_type: "transaction_amount", + operator: ">", + value: 100 + } + }, + actions_attributes: { + "0" => { + id: rule.actions.first.id, + value: "new_value" + }, + "1" => { + action_type: "set_transaction_tags", + value: tags(:one).id + } + } + } + } + end + + rule.reload + + assert_not rule.active + assert_equal "new_value", rule.conditions.order("created_at ASC").first.value + assert_equal "new_value", rule.actions.order("created_at ASC").first.value + assert_equal tags(:one).id, rule.actions.order("created_at ASC").last.value + assert_equal "100", rule.conditions.order("created_at ASC").last.value + + assert_redirected_to rules_url + end + + test "can destroy conditions and actions while editing" do + rule = rules(:one) + + assert_equal 1, rule.conditions.count + assert_equal 1, rule.actions.count + + patch rule_url(rule), params: { + rule: { + conditions_attributes: { + "0" => { id: rule.conditions.first.id, _destroy: true }, + "1" => { + condition_type: "transaction_name", + operator: "like", + value: "new_condition" + } + }, + actions_attributes: { + "0" => { id: rule.actions.first.id, _destroy: true }, + "1" => { + action_type: "set_transaction_tags", + value: tags(:one).id + } + } + } + } + + assert_redirected_to rules_url + + rule.reload + + assert_equal 1, rule.conditions.count + assert_equal 1, rule.actions.count + end + + test "can destroy rule" do + rule = rules(:one) + + assert_difference [ "Rule.count", "Rule::Condition.count", "Rule::Action.count" ], -1 do + delete rule_url(rule) + end + + assert_redirected_to rules_url + end +end diff --git a/test/fixtures/merchants.yml b/test/fixtures/merchants.yml index 3e3ca05a..6ac64ef4 100644 --- a/test/fixtures/merchants.yml +++ b/test/fixtures/merchants.yml @@ -1,13 +1,16 @@ one: + type: FamilyMerchant name: Test family: empty netflix: + type: FamilyMerchant name: Netflix color: "#fd7f6f" family: dylan_family amazon: + type: FamilyMerchant name: Amazon color: "#fd7f6f" family: dylan_family diff --git a/test/fixtures/rule/actions.yml b/test/fixtures/rule/actions.yml new file mode 100644 index 00000000..4a56bf29 --- /dev/null +++ b/test/fixtures/rule/actions.yml @@ -0,0 +1,4 @@ +one: + rule: one + action_type: set_transaction_category + value: "some_category_id" \ No newline at end of file diff --git a/test/fixtures/rule/conditions.yml b/test/fixtures/rule/conditions.yml new file mode 100644 index 00000000..3c140f45 --- /dev/null +++ b/test/fixtures/rule/conditions.yml @@ -0,0 +1,5 @@ +one: + rule: one + condition_type: transaction_name + operator: like + value: "starbucks" diff --git a/test/fixtures/rules.yml b/test/fixtures/rules.yml new file mode 100644 index 00000000..db7a3566 --- /dev/null +++ b/test/fixtures/rules.yml @@ -0,0 +1,3 @@ +one: + family: dylan_family + resource_type: "transaction" \ No newline at end of file diff --git a/test/models/family/auto_categorizer_test.rb b/test/models/family/auto_categorizer_test.rb new file mode 100644 index 00000000..c9440919 --- /dev/null +++ b/test/models/family/auto_categorizer_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Family::AutoCategorizerTest < ActiveSupport::TestCase + include EntriesTestHelper, ProviderTestHelper + + setup do + @family = families(:dylan_family) + @account = @family.accounts.create!(name: "Rule test", balance: 100, currency: "USD", accountable: Depository.new) + @llm_provider = mock + Provider::Registry.stubs(:get_provider).with(:openai).returns(@llm_provider) + end + + test "auto-categorizes transactions" do + txn1 = create_transaction(account: @account, name: "McDonalds").transaction + txn2 = create_transaction(account: @account, name: "Amazon purchase").transaction + txn3 = create_transaction(account: @account, name: "Netflix subscription").transaction + + test_category = @family.categories.create!(name: "Test category") + + provider_response = provider_success_response([ + AutoCategorization.new(transaction_id: txn1.id, category_name: test_category.name), + AutoCategorization.new(transaction_id: txn2.id, category_name: test_category.name), + AutoCategorization.new(transaction_id: txn3.id, category_name: nil) + ]) + + @llm_provider.expects(:auto_categorize).returns(provider_response).once + + assert_difference "DataEnrichment.count", 2 do + Family::AutoCategorizer.new(@family, transaction_ids: [ txn1.id, txn2.id, txn3.id ]).auto_categorize + end + + assert_equal test_category, txn1.reload.category + assert_equal test_category, txn2.reload.category + assert_nil txn3.reload.category + + # After auto-categorization, all transactions are locked and no longer enrichable + assert_equal 0, @account.transactions.reload.enrichable(:category_id).count + end + + private + AutoCategorization = Provider::LlmConcept::AutoCategorization +end diff --git a/test/models/family/auto_merchant_detector_test.rb b/test/models/family/auto_merchant_detector_test.rb new file mode 100644 index 00000000..69c8e3cd --- /dev/null +++ b/test/models/family/auto_merchant_detector_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Family::AutoMerchantDetectorTest < ActiveSupport::TestCase + include EntriesTestHelper, ProviderTestHelper + + setup do + @family = families(:dylan_family) + @account = @family.accounts.create!(name: "Rule test", balance: 100, currency: "USD", accountable: Depository.new) + @llm_provider = mock + Provider::Registry.stubs(:get_provider).with(:openai).returns(@llm_provider) + end + + test "auto detects transaction merchants" do + txn1 = create_transaction(account: @account, name: "McDonalds").transaction + txn2 = create_transaction(account: @account, name: "Chipotle").transaction + txn3 = create_transaction(account: @account, name: "generic").transaction + + provider_response = provider_success_response([ + AutoDetectedMerchant.new(transaction_id: txn1.id, business_name: "McDonalds", business_url: "mcdonalds.com"), + AutoDetectedMerchant.new(transaction_id: txn2.id, business_name: "Chipotle", business_url: "chipotle.com"), + AutoDetectedMerchant.new(transaction_id: txn3.id, business_name: nil, business_url: nil) + ]) + + @llm_provider.expects(:auto_detect_merchants).returns(provider_response).once + + assert_difference "DataEnrichment.count", 2 do + Family::AutoMerchantDetector.new(@family, transaction_ids: [ txn1.id, txn2.id, txn3.id ]).auto_detect + end + + assert_equal "McDonalds", txn1.reload.merchant.name + assert_equal "Chipotle", txn2.reload.merchant.name + assert_equal "https://logo.synthfinance.com/mcdonalds.com", txn1.reload.merchant.logo_url + assert_equal "https://logo.synthfinance.com/chipotle.com", txn2.reload.merchant.logo_url + assert_nil txn3.reload.merchant + + # After auto-detection, all transactions are locked and no longer enrichable + assert_equal 0, @account.transactions.reload.enrichable(:merchant_id).count + end + + private + AutoDetectedMerchant = Provider::LlmConcept::AutoDetectedMerchant +end diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb index e6384297..f9cf3117 100644 --- a/test/models/provider/openai_test.rb +++ b/test/models/provider/openai_test.rb @@ -17,6 +17,103 @@ class Provider::OpenaiTest < ActiveSupport::TestCase end end + test "auto categorizes transactions by various attributes" do + VCR.use_cassette("openai/auto_categorize") do + input_transactions = [ + { id: "1", name: "McDonalds", amount: 20, classification: "expense", merchant: "McDonalds", hint: "Fast Food" }, + { id: "2", name: "Amazon purchase", amount: 100, classification: "expense", merchant: "Amazon" }, + { id: "3", name: "Netflix subscription", amount: 10, classification: "expense", merchant: "Netflix", hint: "Subscriptions" }, + { id: "4", name: "paycheck", amount: 3000, classification: "income" }, + { id: "5", name: "Italian dinner with friends", amount: 100, classification: "expense" }, + { id: "6", name: "1212XXXBCaaa charge", amount: 2.99, classification: "expense" } + ] + + response = @subject.auto_categorize( + transactions: input_transactions, + user_categories: [ + { id: "shopping_id", name: "Shopping", is_subcategory: false, parent_id: nil, classification: "expense" }, + { id: "subscriptions_id", name: "Subscriptions", is_subcategory: true, parent_id: nil, classification: "expense" }, + { id: "restaurants_id", name: "Restaurants", is_subcategory: false, parent_id: nil, classification: "expense" }, + { id: "fast_food_id", name: "Fast Food", is_subcategory: true, parent_id: "restaurants_id", classification: "expense" }, + { id: "income_id", name: "Income", is_subcategory: false, parent_id: nil, classification: "income" } + ] + ) + + assert response.success? + assert_equal input_transactions.size, response.data.size + + txn1 = response.data.find { |c| c.transaction_id == "1" } + txn2 = response.data.find { |c| c.transaction_id == "2" } + txn3 = response.data.find { |c| c.transaction_id == "3" } + txn4 = response.data.find { |c| c.transaction_id == "4" } + txn5 = response.data.find { |c| c.transaction_id == "5" } + txn6 = response.data.find { |c| c.transaction_id == "6" } + + assert_equal "Fast Food", txn1.category_name + assert_equal "Shopping", txn2.category_name + assert_equal "Subscriptions", txn3.category_name + assert_equal "Income", txn4.category_name + assert_equal "Restaurants", txn5.category_name + assert_nil txn6.category_name + end + end + + test "auto detects merchants" do + VCR.use_cassette("openai/auto_detect_merchants") do + input_transactions = [ + { id: "1", name: "McDonalds", amount: 20, classification: "expense" }, + { id: "2", name: "local pub", amount: 20, classification: "expense" }, + { id: "3", name: "WMT purchases", amount: 20, classification: "expense" }, + { id: "4", name: "amzn 123 abc", amount: 20, classification: "expense" }, + { id: "5", name: "chaseX1231", amount: 2000, classification: "income" }, + { id: "6", name: "check deposit 022", amount: 200, classification: "income" }, + { id: "7", name: "shooters bar and grill", amount: 200, classification: "expense" }, + { id: "8", name: "Microsoft Office subscription", amount: 200, classification: "expense" } + ] + + response = @subject.auto_detect_merchants( + transactions: input_transactions, + user_merchants: [ { name: "Shooters" } ] + ) + + assert response.success? + assert_equal input_transactions.size, response.data.size + + txn1 = response.data.find { |c| c.transaction_id == "1" } + txn2 = response.data.find { |c| c.transaction_id == "2" } + txn3 = response.data.find { |c| c.transaction_id == "3" } + txn4 = response.data.find { |c| c.transaction_id == "4" } + txn5 = response.data.find { |c| c.transaction_id == "5" } + txn6 = response.data.find { |c| c.transaction_id == "6" } + txn7 = response.data.find { |c| c.transaction_id == "7" } + txn8 = response.data.find { |c| c.transaction_id == "8" } + + assert_equal "McDonald's", txn1.business_name + assert_equal "mcdonalds.com", txn1.business_url + + assert_nil txn2.business_name + assert_nil txn2.business_url + + assert_equal "Walmart", txn3.business_name + assert_equal "walmart.com", txn3.business_url + + assert_equal "Amazon", txn4.business_name + assert_equal "amazon.com", txn4.business_url + + assert_nil txn5.business_name + assert_nil txn5.business_url + + assert_nil txn6.business_name + assert_nil txn6.business_url + + assert_equal "Shooters", txn7.business_name + assert_nil txn7.business_url + + assert_equal "Microsoft", txn8.business_name + assert_equal "microsoft.com", txn8.business_url + end + end + test "basic chat response" do VCR.use_cassette("openai/chat/basic_response") do response = @subject.chat_response( diff --git a/test/models/provider/plaid/category_alias_matcher_test.rb b/test/models/provider/plaid/category_alias_matcher_test.rb new file mode 100644 index 00000000..11881dea --- /dev/null +++ b/test/models/provider/plaid/category_alias_matcher_test.rb @@ -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 diff --git a/test/models/provider/synth_test.rb b/test/models/provider/synth_test.rb index b489d136..5dfa3bed 100644 --- a/test/models/provider/synth_test.rb +++ b/test/models/provider/synth_test.rb @@ -23,21 +23,4 @@ class Provider::SynthTest < ActiveSupport::TestCase assert usage.plan.present? end end - - test "enriches transaction" do - VCR.use_cassette("synth/transaction_enrich") do - response = @synth.enrich_transaction( - "UBER EATS", - amount: 25.50, - date: Date.iso8601("2025-03-16"), - city: "San Francisco", - state: "CA", - country: "US" - ) - - data = response.data - assert data.name.present? - assert data.category.present? - end - end end diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb new file mode 100644 index 00000000..6cab0bd9 --- /dev/null +++ b/test/models/rule/action_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class Rule::ActionTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:dylan_family) + @transaction_rule = rules(:one) + @account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new) + + @grocery_category = @family.categories.create!(name: "Grocery") + @whole_foods_merchant = @family.merchants.create!(name: "Whole Foods", type: "FamilyMerchant") + + # Some sample transactions to work with + @txn1 = create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant).transaction + @txn2 = create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2").transaction + @txn3 = create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3").transaction + + @rule_scope = @account.transactions + end + + test "set_transaction_category" do + # Does not modify transactions that are locked (user edited them) + @txn1.lock!(:category_id) + + action = Rule::Action.new( + rule: @transaction_rule, + action_type: "set_transaction_category", + value: @grocery_category.id + ) + + action.apply(@rule_scope) + + assert_nil @txn1.reload.category + + [ @txn2, @txn3 ].each do |transaction| + assert_equal @grocery_category.id, transaction.reload.category_id + end + end + + test "set_transaction_tags" do + tag = @family.tags.create!(name: "Rule test tag") + + # Does not modify transactions that are locked (user edited them) + @txn1.lock!(:tag_ids) + + action = Rule::Action.new( + rule: @transaction_rule, + action_type: "set_transaction_tags", + value: tag.id + ) + + action.apply(@rule_scope) + + assert_equal [], @txn1.reload.tags + + [ @txn2, @txn3 ].each do |transaction| + assert_equal [ tag ], transaction.reload.tags + end + end +end diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb new file mode 100644 index 00000000..3010673d --- /dev/null +++ b/test/models/rule/condition_test.rb @@ -0,0 +1,128 @@ +require "test_helper" + +class Rule::ConditionTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @transaction_rule = rules(:one) + @account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new) + + @grocery_category = @family.categories.create!(name: "Grocery") + @whole_foods_merchant = @family.merchants.create!(name: "Whole Foods", type: "FamilyMerchant") + + # Some sample transactions to work with + create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant) + create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2") + create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3") + create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: @whole_foods_merchant) + create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5") + + @rule_scope = @account.transactions + end + + test "applies transaction_name condition" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_name", + operator: "=", + value: "Rule test transaction1" + ) + + scope = condition.prepare(scope) + + assert_equal 5, scope.count + + filtered = condition.apply(scope) + + assert_equal 1, filtered.count + end + + test "applies transaction_amount condition using absolute values" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_amount", + operator: ">", + value: "50" + ) + + scope = condition.prepare(scope) + + filtered = condition.apply(scope) + assert_equal 3, filtered.count + end + + test "applies transaction_merchant condition" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_merchant", + operator: "=", + value: @whole_foods_merchant.id + ) + + scope = condition.prepare(scope) + + filtered = condition.apply(scope) + assert_equal 2, filtered.count + end + + test "applies compound and condition" do + scope = @rule_scope + + parent_condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "compound", + operator: "and", + sub_conditions: [ + Rule::Condition.new( + condition_type: "transaction_merchant", + operator: "=", + value: @whole_foods_merchant.id + ), + Rule::Condition.new( + condition_type: "transaction_amount", + operator: "<", + value: "50" + ) + ] + ) + + scope = parent_condition.prepare(scope) + + filtered = parent_condition.apply(scope) + assert_equal 1, filtered.count + end + + test "applies compound or condition" do + scope = @rule_scope + + parent_condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "compound", + operator: "or", + sub_conditions: [ + Rule::Condition.new( + condition_type: "transaction_merchant", + operator: "=", + value: @whole_foods_merchant.id + ), + Rule::Condition.new( + condition_type: "transaction_amount", + operator: "<", + value: "50" + ) + ] + ) + + scope = parent_condition.prepare(scope) + + filtered = parent_condition.apply(scope) + assert_equal 2, filtered.count + end +end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb new file mode 100644 index 00000000..5cddc644 --- /dev/null +++ b/test/models/rule_test.rb @@ -0,0 +1,77 @@ +require "test_helper" + +class RuleTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new) + @whole_foods_merchant = @family.merchants.create!(name: "Whole Foods", type: "FamilyMerchant") + @groceries_category = @family.categories.create!(name: "Groceries") + end + + test "basic rule" do + transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: @whole_foods_merchant.id) ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + rule.apply + + transaction_entry.reload + + assert_equal @groceries_category, transaction_entry.transaction.category + end + + test "compound rule" do + transaction_entry1 = create_transaction(date: Date.current, amount: 50, account: @account, merchant: @whole_foods_merchant) + transaction_entry2 = create_transaction(date: Date.current, amount: 100, account: @account, merchant: @whole_foods_merchant) + + # Assign "Groceries" to transactions with a merchant of "Whole Foods" and an amount greater than $60 + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ + Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [ + Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: @whole_foods_merchant.id), + Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) + ]) + ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + rule.apply + + transaction_entry1.reload + transaction_entry2.reload + + assert_nil transaction_entry1.transaction.category + assert_equal @groceries_category, transaction_entry2.transaction.category + end + + # Artificial limitation put in place to prevent users from creating overly complex rules + # Rules should be shallow and wide + test "no nested compound conditions" do + rule = Rule.new( + family: @family, + resource_type: "transaction", + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ], + conditions: [ + Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [ + Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [ + Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Starbucks") + ]) + ]) + ] + ) + + assert_not rule.valid? + assert_equal [ "Compound conditions cannot be nested" ], rule.errors.full_messages + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 0fe32c6b..233b8a42 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -10,7 +10,7 @@ class SettingsTest < ApplicationSystemTestCase [ "Accounts", accounts_path ], [ "Tags", tags_path ], [ "Categories", categories_path ], - [ "Merchants", merchants_path ], + [ "Merchants", family_merchants_path ], [ "Imports", imports_path ], [ "What's new", changelog_path ], [ "Feedback", feedback_path ] diff --git a/test/vcr_cassettes/openai/auto_categorize.yml b/test/vcr_cassettes/openai/auto_categorize.yml new file mode 100644 index 00000000..8bd186c0 --- /dev/null +++ b/test/vcr_cassettes/openai/auto_categorize.yml @@ -0,0 +1,194 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-mini","input":[{"role":"developer","content":"Here + are the user''s available categories in JSON format:\n\n```json\n[{\"id\":\"shopping_id\",\"name\":\"Shopping\",\"is_subcategory\":false,\"parent_id\":null,\"classification\":\"expense\"},{\"id\":\"subscriptions_id\",\"name\":\"Subscriptions\",\"is_subcategory\":true,\"parent_id\":null,\"classification\":\"expense\"},{\"id\":\"restaurants_id\",\"name\":\"Restaurants\",\"is_subcategory\":false,\"parent_id\":null,\"classification\":\"expense\"},{\"id\":\"fast_food_id\",\"name\":\"Fast + Food\",\"is_subcategory\":true,\"parent_id\":\"restaurants_id\",\"classification\":\"expense\"},{\"id\":\"income_id\",\"name\":\"Income\",\"is_subcategory\":false,\"parent_id\":null,\"classification\":\"income\"}]\n```\n\nUse + the available categories to auto-categorize the following transactions:\n\n```json\n[{\"id\":\"1\",\"name\":\"McDonalds\",\"amount\":20,\"classification\":\"expense\",\"merchant\":\"McDonalds\",\"hint\":\"Fast + Food\"},{\"id\":\"2\",\"name\":\"Amazon purchase\",\"amount\":100,\"classification\":\"expense\",\"merchant\":\"Amazon\"},{\"id\":\"3\",\"name\":\"Netflix + subscription\",\"amount\":10,\"classification\":\"expense\",\"merchant\":\"Netflix\",\"hint\":\"Subscriptions\"},{\"id\":\"4\",\"name\":\"paycheck\",\"amount\":3000,\"classification\":\"income\"},{\"id\":\"5\",\"name\":\"Italian + dinner with friends\",\"amount\":100,\"classification\":\"expense\"},{\"id\":\"6\",\"name\":\"1212XXXBCaaa + charge\",\"amount\":2.99,\"classification\":\"expense\"}]\n```\n"}],"text":{"format":{"type":"json_schema","name":"auto_categorize_personal_finance_transactions","strict":true,"schema":{"type":"object","properties":{"categorizations":{"type":"array","description":"An + array of auto-categorizations for each transaction","items":{"type":"object","properties":{"transaction_id":{"type":"string","description":"The + internal ID of the original transaction","enum":["1","2","3","4","5","6"]},"category_name":{"type":"string","description":"The + matched category name of the transaction, or null if no match","enum":["Shopping","Subscriptions","Restaurants","Fast + Food","Income","null"]}},"required":["transaction_id","category_name"],"additionalProperties":false}}},"required":["categorizations"],"additionalProperties":false}}},"instructions":"You + are an assistant to a consumer personal finance app. You will be provided + a list\nof the user''s transactions and a list of the user''s categories. Your + job is to auto-categorize\neach transaction.\n\nClosely follow ALL the rules + below while auto-categorizing:\n\n- Return 1 result per transaction\n- Correlate + each transaction by ID (transaction_id)\n- Attempt to match the most specific + category possible (i.e. subcategory over parent category)\n- Category and + transaction classifications should match (i.e. if transaction is an \"expense\", + the category must have classification of \"expense\")\n- If you don''t know + the category, return \"null\"\n - You should always favor \"null\" over false + positives\n - Be slightly pessimistic. Only match a category if you''re + 60%+ confident it is the correct one.\n- Each transaction has varying metadata + that can be used to determine the category\n - Note: \"hint\" comes from + 3rd party aggregators and typically represents a category name that\n may + or may not match any of the user-supplied categories\n"}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 16 Apr 2025 14:07:39 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-r6cwd3mn6iv6gn748b2xoajx + X-Request-Id: + - req_01b869bd9eb7b994a80e79f6de92e5a2 + Openai-Processing-Ms: + - '2173' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=xGkX7L6XeEFLp6ZPB2Y.LLHD_YSpzTH28MUro6fQG7Y-1744812459-1.0.1.1-uy8WQsFzGblq3h.u6WFs2vld_HM.5fveVAFBsQ6y.Za22DSEa22k3NS7.GAUbgAvoVjGvSQlkm8LkSZyU3wZfN70cUpZrg27orQt0Nfq91U; + path=/; expires=Wed, 16-Apr-25 14:37:39 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=LicWzTMZxt1n1GLU6XQx3NnU0PbKnI0m97CH.p0895U-1744812459077-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 93143ffeffe8cf6b-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67ffb9a8e530819290c5d3ec8aaf326d0e0f06e2ac13ae37", + "object": "response", + "created_at": 1744812456, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": "You are an assistant to a consumer personal finance app. You will be provided a list\nof the user's transactions and a list of the user's categories. Your job is to auto-categorize\neach transaction.\n\nClosely follow ALL the rules below while auto-categorizing:\n\n- Return 1 result per transaction\n- Correlate each transaction by ID (transaction_id)\n- Attempt to match the most specific category possible (i.e. subcategory over parent category)\n- Category and transaction classifications should match (i.e. if transaction is an \"expense\", the category must have classification of \"expense\")\n- If you don't know the category, return \"null\"\n - You should always favor \"null\" over false positives\n - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one.\n- Each transaction has varying metadata that can be used to determine the category\n - Note: \"hint\" comes from 3rd party aggregators and typically represents a category name that\n may or may not match any of the user-supplied categories\n", + "max_output_tokens": null, + "model": "gpt-4.1-mini-2025-04-14", + "output": [ + { + "id": "msg_67ffb9a96b3c81928d9da130e889a9aa0e0f06e2ac13ae37", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": "{\"categorizations\":[{\"transaction_id\":\"1\",\"category_name\":\"Fast Food\"},{\"transaction_id\":\"2\",\"category_name\":\"Shopping\"},{\"transaction_id\":\"3\",\"category_name\":\"Subscriptions\"},{\"transaction_id\":\"4\",\"category_name\":\"Income\"},{\"transaction_id\":\"5\",\"category_name\":\"Restaurants\"},{\"transaction_id\":\"6\",\"category_name\":\"null\"}]}" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "json_schema", + "description": null, + "name": "auto_categorize_personal_finance_transactions", + "schema": { + "type": "object", + "properties": { + "categorizations": { + "type": "array", + "description": "An array of auto-categorizations for each transaction", + "items": { + "type": "object", + "properties": { + "transaction_id": { + "type": "string", + "description": "The internal ID of the original transaction", + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6" + ] + }, + "category_name": { + "type": "string", + "description": "The matched category name of the transaction, or null if no match", + "enum": [ + "Shopping", + "Subscriptions", + "Restaurants", + "Fast Food", + "Income", + "null" + ] + } + }, + "required": [ + "transaction_id", + "category_name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "categorizations" + ], + "additionalProperties": false + }, + "strict": true + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 659, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 70, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 729 + }, + "user": null, + "metadata": {} + } + recorded_at: Wed, 16 Apr 2025 14:07:39 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/openai/auto_detect_merchants.yml b/test/vcr_cassettes/openai/auto_detect_merchants.yml new file mode 100644 index 00000000..c8f4a67e --- /dev/null +++ b/test/vcr_cassettes/openai/auto_detect_merchants.yml @@ -0,0 +1,203 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-mini","input":[{"role":"developer","content":"Here + are the user''s available merchants in JSON format:\n\n```json\n[{\"name\":\"Shooters\"}]\n```\n\nUse + BOTH your knowledge AND the user-generated merchants to auto-detect the following + transactions:\n\n```json\n[{\"id\":\"1\",\"name\":\"McDonalds\",\"amount\":20,\"classification\":\"expense\"},{\"id\":\"2\",\"name\":\"local + pub\",\"amount\":20,\"classification\":\"expense\"},{\"id\":\"3\",\"name\":\"WMT + purchases\",\"amount\":20,\"classification\":\"expense\"},{\"id\":\"4\",\"name\":\"amzn + 123 abc\",\"amount\":20,\"classification\":\"expense\"},{\"id\":\"5\",\"name\":\"chaseX1231\",\"amount\":2000,\"classification\":\"income\"},{\"id\":\"6\",\"name\":\"check + deposit 022\",\"amount\":200,\"classification\":\"income\"},{\"id\":\"7\",\"name\":\"shooters + bar and grill\",\"amount\":200,\"classification\":\"expense\"},{\"id\":\"8\",\"name\":\"Microsoft + Office subscription\",\"amount\":200,\"classification\":\"expense\"}]\n```\n\nReturn + \"null\" if you are not 80%+ confident in your answer.\n"}],"text":{"format":{"type":"json_schema","name":"auto_detect_personal_finance_merchants","strict":true,"schema":{"type":"object","properties":{"merchants":{"type":"array","description":"An + array of auto-detected merchant businesses for each transaction","items":{"type":"object","properties":{"transaction_id":{"type":"string","description":"The + internal ID of the original transaction","enum":["1","2","3","4","5","6","7","8"]},"business_name":{"type":["string","null"],"description":"The + detected business name of the transaction, or `null` if uncertain"},"business_url":{"type":["string","null"],"description":"The + URL of the detected business, or `null` if uncertain"}},"required":["transaction_id","business_name","business_url"],"additionalProperties":false}}},"required":["merchants"],"additionalProperties":false}}},"instructions":"You + are an assistant to a consumer personal finance app.\n\nClosely follow ALL + the rules below while auto-detecting business names and website URLs:\n\n- + Return 1 result per transaction\n- Correlate each transaction by ID (transaction_id)\n- + Do not include the subdomain in the business_url (i.e. \"amazon.com\" not + \"www.amazon.com\")\n- User merchants are considered \"manual\" user-generated + merchants and should only be used in 100% clear cases\n- Be slightly pessimistic. We + favor returning \"null\" over returning a false positive.\n- NEVER return + a name or URL for generic transaction names (e.g. \"Paycheck\", \"Laundromat\", + \"Grocery store\", \"Local diner\")\n\nDetermining a value:\n\n- First attempt + to determine the name + URL from your knowledge of global businesses\n- If + no certain match, attempt to match one of the user-provided merchants\n- If + no match, return \"null\"\n\nExample 1 (known business):\n\n```\nTransaction + name: \"Some Amazon purchases\"\n\nResult:\n- business_name: \"Amazon\"\n- + business_url: \"amazon.com\"\n```\n\nExample 2 (generic business):\n\n```\nTransaction + name: \"local diner\"\n\nResult:\n- business_name: null\n- business_url: null\n```\n"}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 16 Apr 2025 15:41:50 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-r6cwd3mn6iv6gn748b2xoajx + X-Request-Id: + - req_77a41d32ae2c3dbd9081b34bc5e4ce61 + Openai-Processing-Ms: + - '2152' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=hCFJRspk322ZVvRasJGcux5mYDyfa5aO7EQOCAbnhjM-1744818110-1.0.1.1-.fRz_SYTG_PqZ3VCSDju7YeDaZwCyf5OGVvDvaN.h3aegNTlYtdPwbnZ5NNFxLRJhWFRY4vwHYkHm1DGTarK5NQ6UjA1sOrRpmS5eZ.zabw; + path=/; expires=Wed, 16-Apr-25 16:11:50 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=At3dVxwug2seJ3Oa02PSnIoKhVSEvt6IPCLfhkULvac-1744818110064-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9314c9f5cef5efe9-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67ffcfbbddb48192a251a3c0f341941a04d20b39fa51ef90", + "object": "response", + "created_at": 1744818107, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": "You are an assistant to a consumer personal finance app.\n\nClosely follow ALL the rules below while auto-detecting business names and website URLs:\n\n- Return 1 result per transaction\n- Correlate each transaction by ID (transaction_id)\n- Do not include the subdomain in the business_url (i.e. \"amazon.com\" not \"www.amazon.com\")\n- User merchants are considered \"manual\" user-generated merchants and should only be used in 100% clear cases\n- Be slightly pessimistic. We favor returning \"null\" over returning a false positive.\n- NEVER return a name or URL for generic transaction names (e.g. \"Paycheck\", \"Laundromat\", \"Grocery store\", \"Local diner\")\n\nDetermining a value:\n\n- First attempt to determine the name + URL from your knowledge of global businesses\n- If no certain match, attempt to match one of the user-provided merchants\n- If no match, return \"null\"\n\nExample 1 (known business):\n\n```\nTransaction name: \"Some Amazon purchases\"\n\nResult:\n- business_name: \"Amazon\"\n- business_url: \"amazon.com\"\n```\n\nExample 2 (generic business):\n\n```\nTransaction name: \"local diner\"\n\nResult:\n- business_name: null\n- business_url: null\n```\n", + "max_output_tokens": null, + "model": "gpt-4.1-mini-2025-04-14", + "output": [ + { + "id": "msg_67ffcfbc58bc8192bbcf4dc54759837c04d20b39fa51ef90", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": "{\"merchants\":[{\"transaction_id\":\"1\",\"business_name\":\"McDonald's\",\"business_url\":\"mcdonalds.com\"},{\"transaction_id\":\"2\",\"business_name\":null,\"business_url\":null},{\"transaction_id\":\"3\",\"business_name\":\"Walmart\",\"business_url\":\"walmart.com\"},{\"transaction_id\":\"4\",\"business_name\":\"Amazon\",\"business_url\":\"amazon.com\"},{\"transaction_id\":\"5\",\"business_name\":null,\"business_url\":null},{\"transaction_id\":\"6\",\"business_name\":null,\"business_url\":null},{\"transaction_id\":\"7\",\"business_name\":\"Shooters\",\"business_url\":null},{\"transaction_id\":\"8\",\"business_name\":\"Microsoft\",\"business_url\":\"microsoft.com\"}]}" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "json_schema", + "description": null, + "name": "auto_detect_personal_finance_merchants", + "schema": { + "type": "object", + "properties": { + "merchants": { + "type": "array", + "description": "An array of auto-detected merchant businesses for each transaction", + "items": { + "type": "object", + "properties": { + "transaction_id": { + "type": "string", + "description": "The internal ID of the original transaction", + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ] + }, + "business_name": { + "type": [ + "string", + "null" + ], + "description": "The detected business name of the transaction, or `null` if uncertain" + }, + "business_url": { + "type": [ + "string", + "null" + ], + "description": "The URL of the detected business, or `null` if uncertain" + } + }, + "required": [ + "transaction_id", + "business_name", + "business_url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "merchants" + ], + "additionalProperties": false + }, + "strict": true + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 635, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 140, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 775 + }, + "user": null, + "metadata": {} + } + recorded_at: Wed, 16 Apr 2025 15:41:50 GMT +recorded_with: VCR 6.3.1