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