1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Transaction rules engine V1 (#1900)

* Domain model sketch

* Scaffold out rules domain

* Migrations

* Remove existing data enrichment for clean slate

* Sketch out business logic and basic tests

* Simplify rule scope building and action executions

* Get generator working again

* Basic implementation + tests

* Remove manual merchant management (rules will replace)

* Revert "Remove manual merchant management (rules will replace)"

This reverts commit 83dcbd9ff0.

* Family and Provider merchants model

* Fix brakeman warnings

* Fix notification loader

* Update notification position

* Add Rule action and condition registries

* Rule form with compound conditions and tests

* Split out notification types, add CTA type

* Rules form builder and Stimulus controller

* Clean up rule registry domain

* Clean up rules stimulus controller

* CTA message for rule when user changes transaction category

* Fix tests

* Lint updates

* Centralize notifications in Notifiable concern

* Implement category rule prompts with auto backoff and option to disable

* Fix layout bug caused by merge conflict

* Initialize rule with correct action for category CTA

* Add rule deletions, get rules working

* Complete dynamic rule form, split Stimulus controllers by resource

* Fix failing tests

* Change test password to avoid chromium conflicts

* Update integration tests

* Centralize all test password references

* Add re-apply rule action

* Rule confirm modal

* Run migrations

* Trigger rule notification after inline category updates

* Clean up rule styles

* Basic attribute locking for rules

* Apply attribute locks on user edits

* Log data enrichments, only apply rules to unlocked attributes

* Fix merge errors

* Additional merge conflict fixes

* Form UI improvements, ignore attribute locks on manual rule application

* Batch AI auto-categorization of transactions

* Auto merchant detection, ai enrichment in batches

* Fix Plaid merchant assignments

* Plaid category matching

* Cleanup 1

* Test cleanup

* Remove stale route

* Fix desktop chat UI issues

* Fix mobile nav styling issues
This commit is contained in:
Zach Gollwitzer 2025-04-18 11:39:58 -04:00 committed by GitHub
parent 8edd7ecef0
commit 297a695d0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 4502 additions and 612 deletions

View file

@ -166,7 +166,6 @@
background: #a6a6a6;
}
}
/* The following Markdown CSS has been removed as requested */
.mt-safe {
margin-top: env(safe-area-inset-top);

View file

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

View file

@ -31,7 +31,7 @@ class AccountsController < ApplicationController
family.sync_later
end
redirect_to accounts_path
redirect_back_or_to accounts_path
end
private

View file

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

View file

@ -56,8 +56,13 @@ class CategoriesController < ApplicationController
redirect_back_or_to categories_path, notice: t(".success")
end
def destroy_all
Current.family.categories.destroy_all
redirect_back_or_to categories_path, notice: "All categories deleted"
end
def bootstrap
Current.family.categories.bootstrap_defaults
Current.family.categories.bootstrap!
redirect_back_or_to categories_path, notice: t(".success")
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@ class TradesController < ApplicationController
def entry_params
params.require(:entry).permit(
:name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
:name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: [ :id, :qty, :price ]
)
end

View file

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

View file

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

View file

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

View file

@ -44,6 +44,6 @@ class ValuationsController < ApplicationController
private
def entry_params
params.require(:entry)
.permit(:name, :enriched_name, :date, :amount, :currency, :notes)
.permit(:name, :date, :amount, :currency, :notes)
end
end

View file

@ -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
# <div>Content here</div>
# <% 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
##

View file

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

View file

@ -4,10 +4,10 @@ module FormsHelper
form_with(**options, &block)
end
def modal_form_wrapper(title:, subtitle: nil, &block)
def modal_form_wrapper(title:, subtitle: nil, overflow_visible: false, &block)
content = capture &block
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
render partial: "shared/modal_form", locals: { title:, subtitle:, content:, overflow_visible: }
end
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false, class: nil)

View file

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

View file

@ -22,7 +22,7 @@ export default class extends Controller {
this.element.close();
if (this.reloadOnCloseValue) {
window.location.reload();
Turbo.visit(window.location.href);
}
}
}

View file

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

View file

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

View file

@ -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();
}
}

View file

@ -0,0 +1,7 @@
class AutoCategorizeJob < ApplicationJob
queue_as :medium_priority
def perform(family, transaction_ids: [])
family.auto_categorize_transactions(transaction_ids)
end
end

View file

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

View file

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

7
app/jobs/rule_job.rb Normal file
View file

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

View file

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

View file

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

View file

@ -50,11 +50,11 @@ class Category < ApplicationRecord
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
end
def bootstrap_defaults
default_categories.each do |name, color, icon|
def bootstrap!
default_categories.each do |name, color, icon, classification|
find_or_create_by!(name: name) do |category|
category.color = color
category.classification = "income" if name == "Income"
category.classification = classification
category.lucide_icon = icon
end
end
@ -71,18 +71,20 @@ class Category < ApplicationRecord
private
def default_categories
[
[ "Income", "#e99537", "circle-dollar-sign" ],
[ "Housing", "#6471eb", "house" ],
[ "Entertainment", "#df4e92", "drama" ],
[ "Food & Drink", "#eb5429", "utensils" ],
[ "Shopping", "#e99537", "shopping-cart" ],
[ "Healthcare", "#4da568", "pill" ],
[ "Insurance", "#6471eb", "piggy-bank" ],
[ "Utilities", "#db5a54", "lightbulb" ],
[ "Transportation", "#df4e92", "bus" ],
[ "Education", "#eb5429", "book" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
[ "Subscriptions", "#805dee", "credit-card" ]
[ "Income", "#e99537", "circle-dollar-sign", "income" ],
[ "Loan Payments", "#6471eb", "credit-card", "expense" ],
[ "Fees", "#6471eb", "credit-card", "expense" ],
[ "Entertainment", "#df4e92", "drama", "expense" ],
[ "Food & Drink", "#eb5429", "utensils", "expense" ],
[ "Shopping", "#e99537", "shopping-cart", "expense" ],
[ "Home Improvement", "#6471eb", "house", "expense" ],
[ "Healthcare", "#4da568", "pill", "expense" ],
[ "Personal Care", "#4da568", "pill", "expense" ],
[ "Services", "#4da568", "briefcase", "expense" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ],
[ "Transportation", "#df4e92", "bus", "expense" ],
[ "Travel", "#df4e92", "plane", "expense" ],
[ "Rent & Utilities", "#db5a54", "lightbulb", "expense" ]
]
end
end

View file

@ -9,6 +9,8 @@ module Accountable
end
included do
include Enrichable
has_one :account, as: :accountable, touch: true
end

View file

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

View file

@ -0,0 +1,5 @@
class DataEnrichment < ApplicationRecord
belongs_to :enrichable, polymorphic: true
enum :source, { rule: "rule", plaid: "plaid", synth: "synth", ai: "ai" }
end

View file

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

View file

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

View file

@ -16,7 +16,7 @@ class EntrySearch
return scope if search.blank?
query = scope
query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search",
query = query.where("entries.name ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
)
query

View file

@ -8,6 +8,8 @@ module Entryable
end
included do
include Enrichable
has_one :entry, as: :entryable, touch: true
scope :with_entry, -> { joins(:entry) }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,13 +83,14 @@ class PlaidAccount < ApplicationRecord
def sync_transactions!(added:, modified:, removed:)
added.each do |plaid_txn|
account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t|
t.name = plaid_txn.name
t.name = plaid_txn.merchant_name || plaid_txn.original_description
t.amount = plaid_txn.amount
t.currency = plaid_txn.iso_currency_code
t.date = plaid_txn.date
t.entryable = Transaction.new(
category: get_category(plaid_txn.personal_finance_category.primary),
merchant: get_merchant(plaid_txn.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)

View file

@ -80,6 +80,7 @@ class PlaidItem < ApplicationRecord
end
def post_sync(sync)
auto_match_categories!
family.broadcast_refresh
end
@ -88,6 +89,36 @@ class PlaidItem < ApplicationRecord
DestroyJob.perform_later(self)
end
def auto_match_categories!
if family.categories.none?
family.categories.bootstrap!
end
alias_matcher = build_category_alias_matcher(family.categories)
accounts.each do |account|
matchable_transactions = account.transactions
.where(category_id: nil)
.where.not(plaid_category: nil)
.enrichable(:category_id)
matchable_transactions.each do |transaction|
category = alias_matcher.match(transaction.plaid_category_detailed)
if category.present?
PlaidItem.transaction do
transaction.log_enrichment!(
attribute_name: "category_id",
attribute_value: category.id,
source: "plaid"
)
transaction.set_category!(category)
end
end
end
end
end
private
def fetch_and_load_plaid_data
data = {}

View file

@ -15,6 +15,10 @@ module PlaidItem::Provided
end
end
def build_category_alias_matcher(user_categories)
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
end
private
def eu?
raise "eu? is not implemented for #{self.class.name}"

View file

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

View file

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

View file

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

View file

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

View file

@ -121,7 +121,10 @@ class Provider::Plaid
while has_more
request = Plaid::TransactionsSyncRequest.new(
access_token: item.access_token,
cursor: cursor
cursor: cursor,
options: {
include_original_description: true
}
)
response = client.transactions_sync(request)

View file

@ -0,0 +1,109 @@
# The purpose of this matcher is to auto-match Plaid categories to
# known internal user categories. Since we allow users to define their own
# categories we cannot directly assign Plaid categories as this would overwrite
# user data and create a confusing experience.
#
# Automated category matching in the Maybe app has a hierarchy:
# 1. Naive string matching via CategoryAliasMatcher
# 2. Rules-based matching set by user
# 3. AI-powered matching (also enabled by user via rules)
#
# This class is simply a FAST and CHEAP way to match categories that are high confidence.
# Edge cases will be handled by user-defined rules.
class Provider::Plaid::CategoryAliasMatcher
include Provider::Plaid::CategoryTaxonomy
def initialize(user_categories)
@user_categories = user_categories
end
def match(plaid_detailed_category)
plaid_category_details = get_plaid_category_details(plaid_detailed_category)
return nil unless plaid_category_details
# Try exact name matches first
exact_match = normalized_user_categories.find do |category|
category[:name] == plaid_category_details[:key].to_s
end
return user_categories.find { |c| c.id == exact_match[:id] } if exact_match
# Try detailed aliases matches with fuzzy matching
alias_match = normalized_user_categories.find do |category|
name = category[:name]
plaid_category_details[:aliases].any? do |a|
alias_str = a.to_s
# Try exact match
next true if name == alias_str
# Try plural forms
next true if name.singularize == alias_str || name.pluralize == alias_str
next true if alias_str.singularize == name || alias_str.pluralize == name
# Try common forms
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
normalized_name == normalized_alias
end
end
return user_categories.find { |c| c.id == alias_match[:id] } if alias_match
# Try parent aliases matches with fuzzy matching
parent_match = normalized_user_categories.find do |category|
name = category[:name]
plaid_category_details[:parent_aliases].any? do |a|
alias_str = a.to_s
# Try exact match
next true if name == alias_str
# Try plural forms
next true if name.singularize == alias_str || name.pluralize == alias_str
next true if alias_str.singularize == name || alias_str.pluralize == name
# Try common forms
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
normalized_name == normalized_alias
end
end
return user_categories.find { |c| c.id == parent_match[:id] } if parent_match
nil
end
private
attr_reader :user_categories
def get_plaid_category_details(plaid_category_name)
detailed_plaid_categories.find { |c| c[:key] == plaid_category_name.downcase.to_sym }
end
def detailed_plaid_categories
CATEGORIES_MAP.flat_map do |parent_key, parent_data|
parent_data[:detailed_categories].map do |child_key, child_data|
{
key: child_key,
classification: child_data[:classification],
aliases: child_data[:aliases],
parent_key: parent_key,
parent_aliases: parent_data[:aliases]
}
end
end
end
def normalized_user_categories
user_categories.map do |user_category|
{
id: user_category.id,
classification: user_category.classification,
name: normalize_user_category_name(user_category.name)
}
end
end
def normalize_user_category_name(name)
name.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip
end
end

View file

@ -0,0 +1,461 @@
# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
module Provider::Plaid::CategoryTaxonomy
CATEGORIES_MAP = {
income: {
classification: :income,
aliases: [ "income", "revenue", "earnings" ],
detailed_categories: {
income_dividends: {
classification: :income,
aliases: [ "dividend", "stock income", "dividend income", "dividend earnings" ]
},
income_interest_earned: {
classification: :income,
aliases: [ "interest", "bank interest", "interest earned", "interest income" ]
},
income_retirement_pension: {
classification: :income,
aliases: [ "retirement", "pension" ]
},
income_tax_refund: {
classification: :income,
aliases: [ "tax refund" ]
},
income_unemployment: {
classification: :income,
aliases: [ "unemployment" ]
},
income_wages: {
classification: :income,
aliases: [ "wage", "salary", "paycheck" ]
},
income_other_income: {
classification: :income,
aliases: [ "other income", "misc income" ]
}
}
},
loan_payments: {
classification: :expense,
aliases: [ "loan payment", "debt payment", "loan", "debt", "payment" ],
detailed_categories: {
loan_payments_car_payment: {
classification: :expense,
aliases: [ "car payment", "auto loan" ]
},
loan_payments_credit_card_payment: {
classification: :expense,
aliases: [ "credit card", "card payment" ]
},
loan_payments_personal_loan_payment: {
classification: :expense,
aliases: [ "personal loan", "loan payment" ]
},
loan_payments_mortgage_payment: {
classification: :expense,
aliases: [ "mortgage", "home loan" ]
},
loan_payments_student_loan_payment: {
classification: :expense,
aliases: [ "student loan", "education loan" ]
},
loan_payments_other_payment: {
classification: :expense,
aliases: [ "loan", "loan payment" ]
}
}
},
bank_fees: {
classification: :expense,
aliases: [ "bank fee", "service charge", "fee", "misc fees" ],
detailed_categories: {
bank_fees_atm_fees: {
classification: :expense,
aliases: [ "atm fee", "withdrawal fee" ]
},
bank_fees_foreign_transaction_fees: {
classification: :expense,
aliases: [ "foreign fee", "international fee" ]
},
bank_fees_insufficient_funds: {
classification: :expense,
aliases: [ "nsf fee", "overdraft" ]
},
bank_fees_interest_charge: {
classification: :expense,
aliases: [ "interest charge", "finance charge" ]
},
bank_fees_overdraft_fees: {
classification: :expense,
aliases: [ "overdraft fee" ]
},
bank_fees_other_bank_fees: {
classification: :expense,
aliases: [ "bank fee", "service charge" ]
}
}
},
entertainment: {
classification: :expense,
aliases: [ "entertainment", "recreation" ],
detailed_categories: {
entertainment_casinos_and_gambling: {
classification: :expense,
aliases: [ "casino", "gambling" ]
},
entertainment_music_and_audio: {
classification: :expense,
aliases: [ "music", "concert" ]
},
entertainment_sporting_events_amusement_parks_and_museums: {
classification: :expense,
aliases: [ "event", "amusement", "museum" ]
},
entertainment_tv_and_movies: {
classification: :expense,
aliases: [ "movie", "streaming" ]
},
entertainment_video_games: {
classification: :expense,
aliases: [ "game", "gaming" ]
},
entertainment_other_entertainment: {
classification: :expense,
aliases: [ "entertainment", "recreation" ]
}
}
},
food_and_drink: {
classification: :expense,
aliases: [ "food", "dining", "food and drink", "food & drink" ],
detailed_categories: {
food_and_drink_beer_wine_and_liquor: {
classification: :expense,
aliases: [ "alcohol", "liquor", "beer", "wine", "bar", "pub" ]
},
food_and_drink_coffee: {
classification: :expense,
aliases: [ "coffee", "cafe", "coffee shop" ]
},
food_and_drink_fast_food: {
classification: :expense,
aliases: [ "fast food", "takeout" ]
},
food_and_drink_groceries: {
classification: :expense,
aliases: [ "grocery", "supermarket", "grocery store" ]
},
food_and_drink_restaurant: {
classification: :expense,
aliases: [ "restaurant", "dining" ]
},
food_and_drink_vending_machines: {
classification: :expense,
aliases: [ "vending" ]
},
food_and_drink_other_food_and_drink: {
classification: :expense,
aliases: [ "food", "drink" ]
}
}
},
general_merchandise: {
classification: :expense,
aliases: [ "shopping", "retail" ],
detailed_categories: {
general_merchandise_bookstores_and_newsstands: {
classification: :expense,
aliases: [ "book", "newsstand" ]
},
general_merchandise_clothing_and_accessories: {
classification: :expense,
aliases: [ "clothing", "apparel" ]
},
general_merchandise_convenience_stores: {
classification: :expense,
aliases: [ "convenience" ]
},
general_merchandise_department_stores: {
classification: :expense,
aliases: [ "department store" ]
},
general_merchandise_discount_stores: {
classification: :expense,
aliases: [ "discount store" ]
},
general_merchandise_electronics: {
classification: :expense,
aliases: [ "electronic", "computer" ]
},
general_merchandise_gifts_and_novelties: {
classification: :expense,
aliases: [ "gift", "souvenir" ]
},
general_merchandise_office_supplies: {
classification: :expense,
aliases: [ "office supply" ]
},
general_merchandise_online_marketplaces: {
classification: :expense,
aliases: [ "online shopping" ]
},
general_merchandise_pet_supplies: {
classification: :expense,
aliases: [ "pet supply", "pet food" ]
},
general_merchandise_sporting_goods: {
classification: :expense,
aliases: [ "sporting good", "sport" ]
},
general_merchandise_superstores: {
classification: :expense,
aliases: [ "superstore", "retail" ]
},
general_merchandise_tobacco_and_vape: {
classification: :expense,
aliases: [ "tobacco", "smoke" ]
},
general_merchandise_other_general_merchandise: {
classification: :expense,
aliases: [ "shopping", "merchandise" ]
}
}
},
home_improvement: {
classification: :expense,
aliases: [ "home", "house", "house renovation", "home improvement", "renovation" ],
detailed_categories: {
home_improvement_furniture: {
classification: :expense,
aliases: [ "furniture", "furnishing" ]
},
home_improvement_hardware: {
classification: :expense,
aliases: [ "hardware", "tool" ]
},
home_improvement_repair_and_maintenance: {
classification: :expense,
aliases: [ "repair", "maintenance" ]
},
home_improvement_security: {
classification: :expense,
aliases: [ "security", "alarm" ]
},
home_improvement_other_home_improvement: {
classification: :expense,
aliases: [ "home improvement", "renovation" ]
}
}
},
medical: {
classification: :expense,
aliases: [ "medical", "healthcare", "health" ],
detailed_categories: {
medical_dental_care: {
classification: :expense,
aliases: [ "dental", "dentist" ]
},
medical_eye_care: {
classification: :expense,
aliases: [ "eye", "optometrist" ]
},
medical_nursing_care: {
classification: :expense,
aliases: [ "nursing", "care" ]
},
medical_pharmacies_and_supplements: {
classification: :expense,
aliases: [ "pharmacy", "prescription" ]
},
medical_primary_care: {
classification: :expense,
aliases: [ "doctor", "medical" ]
},
medical_veterinary_services: {
classification: :expense,
aliases: [ "vet", "veterinary" ]
},
medical_other_medical: {
classification: :expense,
aliases: [ "medical", "healthcare" ]
}
}
},
personal_care: {
classification: :expense,
aliases: [ "personal care", "grooming" ],
detailed_categories: {
personal_care_gyms_and_fitness_centers: {
classification: :expense,
aliases: [ "gym", "fitness", "exercise", "sport" ]
},
personal_care_hair_and_beauty: {
classification: :expense,
aliases: [ "salon", "beauty" ]
},
personal_care_laundry_and_dry_cleaning: {
classification: :expense,
aliases: [ "laundry", "cleaning" ]
},
personal_care_other_personal_care: {
classification: :expense,
aliases: [ "personal care", "grooming" ]
}
}
},
general_services: {
classification: :expense,
aliases: [ "service", "professional service" ],
detailed_categories: {
general_services_accounting_and_financial_planning: {
classification: :expense,
aliases: [ "accountant", "financial advisor" ]
},
general_services_automotive: {
classification: :expense,
aliases: [ "auto repair", "mechanic", "vehicle", "car", "car care", "car maintenance", "vehicle maintenance" ]
},
general_services_childcare: {
classification: :expense,
aliases: [ "childcare", "daycare" ]
},
general_services_consulting_and_legal: {
classification: :expense,
aliases: [ "legal", "attorney" ]
},
general_services_education: {
classification: :expense,
aliases: [ "education", "tuition" ]
},
general_services_insurance: {
classification: :expense,
aliases: [ "insurance", "premium" ]
},
general_services_postage_and_shipping: {
classification: :expense,
aliases: [ "shipping", "postage" ]
},
general_services_storage: {
classification: :expense,
aliases: [ "storage" ]
},
general_services_other_general_services: {
classification: :expense,
aliases: [ "service" ]
}
}
},
government_and_non_profit: {
classification: :expense,
aliases: [ "government", "non-profit" ],
detailed_categories: {
government_and_non_profit_donations: {
classification: :expense,
aliases: [ "donation", "charity", "charitable", "charitable donation", "giving", "gifts and donations", "gifts & donations" ]
},
government_and_non_profit_government_departments_and_agencies: {
classification: :expense,
aliases: [ "government", "agency" ]
},
government_and_non_profit_tax_payment: {
classification: :expense,
aliases: [ "tax payment", "tax" ]
},
government_and_non_profit_other_government_and_non_profit: {
classification: :expense,
aliases: [ "government", "non-profit" ]
}
}
},
transportation: {
classification: :expense,
aliases: [ "transportation", "travel" ],
detailed_categories: {
transportation_bikes_and_scooters: {
classification: :expense,
aliases: [ "bike", "scooter" ]
},
transportation_gas: {
classification: :expense,
aliases: [ "gas", "fuel" ]
},
transportation_parking: {
classification: :expense,
aliases: [ "parking" ]
},
transportation_public_transit: {
classification: :expense,
aliases: [ "transit", "bus" ]
},
transportation_taxis_and_ride_shares: {
classification: :expense,
aliases: [ "taxi", "rideshare" ]
},
transportation_tolls: {
classification: :expense,
aliases: [ "toll" ]
},
transportation_other_transportation: {
classification: :expense,
aliases: [ "transportation", "travel" ]
}
}
},
travel: {
classification: :expense,
aliases: [ "travel", "vacation", "trip", "sabbatical" ],
detailed_categories: {
travel_flights: {
classification: :expense,
aliases: [ "flight", "airfare" ]
},
travel_lodging: {
classification: :expense,
aliases: [ "hotel", "lodging" ]
},
travel_rental_cars: {
classification: :expense,
aliases: [ "rental car" ]
},
travel_other_travel: {
classification: :expense,
aliases: [ "travel", "trip" ]
}
}
},
rent_and_utilities: {
classification: :expense,
aliases: [ "utilities", "housing", "house", "home", "rent", "rent & utilities" ],
detailed_categories: {
rent_and_utilities_gas_and_electricity: {
classification: :expense,
aliases: [ "utility", "electric" ]
},
rent_and_utilities_internet_and_cable: {
classification: :expense,
aliases: [ "internet", "cable" ]
},
rent_and_utilities_rent: {
classification: :expense,
aliases: [ "rent", "lease" ]
},
rent_and_utilities_sewage_and_waste_management: {
classification: :expense,
aliases: [ "sewage", "waste" ]
},
rent_and_utilities_telephone: {
classification: :expense,
aliases: [ "phone", "telephone" ]
},
rent_and_utilities_water: {
classification: :expense,
aliases: [ "water" ]
},
rent_and_utilities_other_utilities: {
classification: :expense,
aliases: [ "utility" ]
}
}
}
}
end

View file

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

View file

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

90
app/models/rule.rb Normal file
View file

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

17
app/models/rule/action.rb Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,14 +32,7 @@
</p>
<% unless account.scheduled_for_deletion? %>
<%= form_with model: account,
namespace: account.id,
data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %>
<div class="relative inline-block select-none">
<%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
<%= form.label :is_active, "&nbsp;".html_safe, class: "switch" %>
</div>
<% end %>
<%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %>
<% end %>
</div>
</div>

View file

@ -6,7 +6,7 @@
</p>
<div class="flex items-center gap-2">
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
<%= button_to "Use defaults (recommended)", bootstrap_categories_path, class: "btn btn--primary" %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>

View file

@ -10,20 +10,18 @@
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
<% if category.transactions.any? %>
<%= link_to new_category_deletion_path(category),
<% if category.transactions.any? %>
<%= link_to new_category_deletion_path(category),
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
<span class="text-sm"><%= t(".delete") %></span>
<% end %>
<% else %>
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
<span class="text-sm"><%= t(".delete") %></span>
<% end %>
</div>
<% else %>
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
<% end %>
<% end %>
</div>
</div>

View file

@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".edit") do %>
<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %>
<%= render "form", category: @category, categories: @categories %>
<% end %>

View file

@ -1,11 +1,20 @@
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium"><%= t(".categories") %></h1>
<%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<p><%= t(".new") %></p>
<% end %>
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<%= contextual_menu_destructive_item "Delete all", destroy_all_categories_path, turbo_confirm: {
title: "Delete all categories?",
body: "All of your transactions will become uncategorized and this cannot be undone.",
accept: "Delete all categories",
} %>
<% end %>
<%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<p><%= t(".new") %></p>
<% end %>
</div>
</header>
<div class="bg-container shadow-border-xs rounded-xl p-4">

View file

@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".new_category") do %>
<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %>
<%= render "form", category: @category, categories: @categories %>
<% end %>

View file

@ -7,7 +7,7 @@
<% end %>
</nav>
<div class="grow">
<div class="grow flex flex-col">
<h1 class="text-xl font-medium mb-6">Chats</h1>
<% if @chats.any? %>
@ -22,7 +22,7 @@
<h3 class="text-lg font-medium text-primary mb-1">No chats yet</h3>
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
</div>
<div class="mt-auto p-4">
<div class="mt-auto p-4 lg:mt-auto">
<%= render "messages/chat_form", chat: nil %>
</div>
<% end %>

View file

@ -28,7 +28,7 @@
<% end %>
</div>
<div class="p-4">
<div class="p-4 lg:mt-auto">
<%= render "messages/chat_form", chat: @chat %>
</div>
</div>

View file

@ -0,0 +1,31 @@
<%# locals: (family_merchant:) %>
<div class="flex justify-between items-center p-4 bg-white">
<div class="flex w-full items-center gap-2.5">
<% if family_merchant.logo_url %>
<div class="w-8 h-8 rounded-full flex justify-center items-center">
<%= image_tag family_merchant.logo_url, class: "w-8 h-8 rounded-full" %>
</div>
<% else %>
<%= render partial: "shared/color_avatar", locals: { name: family_merchant.name, color: family_merchant.color } %>
<% end %>
<p class="text-primary text-sm truncate">
<%= family_merchant.name %>
</p>
</div>
<div class="justify-self-end">
<%= 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 %>
</div>
</div>

View file

@ -1,11 +1,14 @@
<div data-controller="color-avatar">
<%= 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| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<% if @merchant.errors.any? %>
<%= render "shared/form_errors", model: @merchant %>
<% end %>
<div class="w-fit m-auto mb-4">
<%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
</div>
<div class="flex gap-2 items-center justify-center">
<% Merchant::COLORS.each do |color| %>
<% FamilyMerchant::COLORS.each do |color| %>
<label class="relative">
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>

View file

@ -1,9 +1,9 @@
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium"><%= t(".title") %></h1>
<h1 class="text-primary text-xl font-medium">Merchants</h1>
<%= link_to new_merchant_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<p><%= t(".new") %></p>
<%= link_to new_family_merchant_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium">New merchant</p>
<% end %>
</header>
@ -18,7 +18,7 @@
<div class="border border-alpha-black-25 rounded-md bg-container shadow-border-xs">
<div class="overflow-hidden rounded-md">
<%= render partial: @merchants, spacer_template: "merchants/ruler" %>
<%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "family_merchants/ruler" %>
</div>
</div>
</div>
@ -26,7 +26,7 @@
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-primary mb-1 font-medium text-sm"><%= t(".empty") %></p>
<%= link_to new_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= 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") %>
<span><%= t(".new") %></span>
<% end %>

View file

@ -5,56 +5,58 @@
data-controller="sidebar"
data-sidebar-user-id-value="<%= Current.user.id %>"
data-sidebar-config-value="<%= sidebar_config.to_json %>">
<button hidden data-controller="hotkey" data-hotkey="b" data-action="sidebar#toggleLeftPanel">Toggle accounts</button>
<button hidden data-controller="hotkey" data-hotkey="l" data-action="sidebar#toggleRightPanel">Toggle chat</button>
<% unless controller_name == 'chats' %>
<nav class="flex justify-between lg:justify-start lg:flex-col shrink-0 lg:w-[84px] p-3 lg:px-0 lg:py-4 lg:mr-3">
<nav class="flex justify-between lg:justify-start lg:flex-col shrink-0 lg:w-[84px] p-3 lg:px-0 lg:py-4 lg:mr-3">
<button data-action="sidebar#toggleLeftPanelMobile" class="lg:hidden inline-flex p-2 rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
<%= icon("panel-left", color: "gray") %>
</button>
<button data-action="sidebar#toggleLeftPanelMobile" class="lg:hidden inline-flex p-2 rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
<%= icon("panel-left", color: "gray") %>
</button>
<%# Mobile only account sidebar groups %>
<%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
<%# Mobile only account sidebar groups %>
<%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
data: { sidebar_target: "leftPanelMobile" } do %>
<div id="account-sidebar-tabs">
<div class="mb-4">
<button data-action="sidebar#toggleLeftPanelMobile">
<%= icon("x", color: "gray") %>
</button>
<div id="account-sidebar-tabs" class="pt-6">
<div class="mb-4">
<button data-action="sidebar#toggleLeftPanelMobile">
<%= icon("x", color: "gray") %>
</button>
</div>
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
</div>
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
</div>
<% end %>
<div class="lg:pl-2 lg:mb-3">
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<ul class="space-y-0.5 hidden lg:block">
<li>
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
</li>
</ul>
<div class="lg:pl-2 lg:mt-auto lg:mx-auto">
<div class="lg:hidden">
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
<div class="lg:pl-2 lg:mb-3">
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<div class="hidden lg:block">
<%= render "users/user_menu", user: Current.user %>
<ul class="space-y-0.5 hidden lg:block">
<li>
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
</li>
</ul>
<div class="lg:pl-2 lg:mt-auto lg:mx-auto">
<div class="lg:hidden">
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
</div>
<div class="hidden lg:block">
<%= render "users/user_menu", user: Current.user %>
</div>
</div>
</div>
</nav>
</nav>
<% end %>
<div class="flex justify-between lg:justify-normal grow overflow-y-auto">
@ -110,7 +112,7 @@
<% end %>
</div>
<nav class="lg:hidden bg-surface md:bg-container shrink-0 z-10 pb-2 border border-tertiary pb-safe">
<nav class="lg:hidden bg-surface md:bg-container shrink-0 z-10 pb-2 border-t border-tertiary pb-safe">
<ul class="flex items-center justify-around gap-1">
<li>
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>

View file

@ -6,17 +6,18 @@
</head>
<body class="h-screen overflow-hidden lg:overflow-auto antialiased h-screen-safe ">
<div class="fixed z-50 top-6 md:top-auto md:bottom-6 md:left-24 w-full md:w-80 mx-auto md:mx-0 md:right-auto mt-safe">
<div class="fixed z-50 top-6 md:top-4 md:left-1/2 -translate-x-1/2 w-full md:w-80 mx-auto md:mx-0 md:right-auto mt-safe">
<div id="notification-tray" class="space-y-1 w-full">
<%= render_flash_notifications %>
<div id="cta"></div>
<% if Current.family&.syncing? %>
<%= render "shared/syncing_notice" %>
<% render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
<% end %>
</div>
</div>
<%= family_notifications_stream %>
<%= family_stream %>
<%= turbo_frame_tag "modal" %>

View file

@ -1,12 +0,0 @@
<div class="fixed z-50 bottom-6 left-6">
<div id="notification-tray" class="space-y-1">
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>
<%= render "shared/syncing_notice" %>
<% end %>
</div>
</div>
<%= family_notifications_stream %>
<%= family_stream %>

View file

@ -1,18 +1,16 @@
<%# locals: (name:, path:, icon_key:, is_custom: false) %>
<%= link_to path, class: "space-y-1 lg:py-1 group block relative" do %>
<% if page_active?(path) %>
<%= tag.div class: "w-4 h-1 bg-nav-indicator rounded-bl-sm rounded-br-sm absolute top-0 left-1/2 -translate-x-1/2 lg:hidden" %>
<% end %>
<%= link_to path, class: "space-y-1 group block relative pb-1" do %>
<div class="grow flex flex-col lg:flex-row gap-1 items-center">
<%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => page_active?(path)) %>
<% icon_color = page_active?(path) ? "current" : "gray" %>
<%= tag.div class: class_names("w-8 h-8 flex items-center justify-center mx-auto rounded-lg", page_active?(path) ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary") do %>
<%= is_custom ? icon_custom(icon_key, color: icon_color) : icon(icon_key, color: icon_color) %>
<% end %>
</div>
<div class="grow flex justify-center">
<div class="grow flex justify-center lg:pl-2">
<%= tag.p class: class_names("font-medium text-[11px]", page_active?(path) ? "text-primary" : "text-secondary") do %>
<%= name %>
<% end %>

View file

@ -4,25 +4,32 @@
<% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>
<%= form_with model: model,
class: "flex items-center gap-2 bg-container p-2 rounded-full shadow-sm border border-gray-100 h-11",
class: "flex lg:flex-col gap-2 bg-container px-2 py-1.5 rounded-full lg:rounded-lg shadow-border-xs",
data: { chat_target: "form" } do |f| %>
<%# In the future, this will be a dropdown with different AI models %>
<%= f.hidden_field :ai_model, value: "gpt-4o" %>
<button type="button" class="flex-shrink-0 text-secondary p-1">
<%= lucide_icon("plus", class: "w-5 h-5") %>
</button>
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
class: "w-full border-0 focus:ring-0 text-sm resize-none bg-transparent py-0",
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1 bg-transparent",
data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" },
rows: 1 %>
<button type="submit" class="flex-shrink-0 text-secondary bg-gray-50 rounded-full p-2">
<%= lucide_icon("arrow-up", class: "w-4 h-4") %>
</button>
<div class="flex items-center justify-between gap-1">
<div class="items-center gap-1 hidden lg:flex">
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
<button type="button" title="Coming soon" class="cursor-not-allowed w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
<%= icon(icon, color: "gray") %>
</button>
<% end %>
</div>
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
<%= icon("arrow-up") %>
</button>
</div>
<% end %>
<p class="text-xs text-secondary mt-1">AI responses are informational only and are not financial advice.</p>
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
</div>

View file

@ -0,0 +1,27 @@
<%# locals: (form:) %>
<% action = form.object %>
<% rule = action.rule %>
<% needs_value = action.executor.type == "select" %>
<li data-controller="rule--actions" data-rule--actions-action-executors-value="<%= rule.action_executors.to_json %>" class="flex items-center gap-3">
<%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: "destroyField" } %>
<div class="grow flex gap-2 items-center h-full">
<div class="grow">
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
</div>
<%= tag.div class: class_names("min-w-1/2 flex items-center gap-2", "hidden" => !needs_value),
data: { rule__actions_target: "actionValue" } do %>
<span class="font-medium uppercase text-xs">to</span>
<%= form.select :value, action.options || [], {}, disabled: !needs_value %>
<% end %>
</div>
<button type="button"
data-action="rule--actions#remove"
data-rule--actions-destroy-param="<%= action.persisted? %>">
<%= icon("trash-2", color: "gray", size: "sm") %>
</button>
</li>

View file

@ -0,0 +1,40 @@
<%# locals: (form:, show_prefix: true) %>
<% condition = form.object %>
<% rule = condition.rule %>
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
<% if form.index.to_i > 0 && show_prefix %>
<div class="pl-4">
<span class="font-medium uppercase text-xs">and</span>
</div>
<% end %>
<div class="grow flex gap-2 items-center h-full">
<%= form.hidden_field :_destroy, value: false, data: { rule__conditions_target: "destroyField" } %>
<div class="w-2/5 shrink-0">
<%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule--conditions#handleConditionTypeChange" } %>
</div>
<%= form.select :operator, condition.operators, { container_class: "w-fit min-w-36" }, data: { rule__conditions_target: "operatorSelect" } %>
<div data-rule--conditions-target="filterValue" class="grow">
<% if condition.filter.type == "select" %>
<%= form.select :value, condition.options, {} %>
<% else %>
<% if condition.filter.type == "number" %>
<%= form.number_field :value, placeholder: "10", step: 0.01 %>
<% else %>
<%= form.text_field :value, placeholder: "Enter a value" %>
<% end %>
<% end %>
</div>
</div>
<button type="button"
data-action="rule--conditions#remove"
data-rule--conditions-destroy-param="<%= condition.persisted? %>">
<%= icon("trash-2", color: "gray", size: "sm") %>
</button>
</li>

View file

@ -0,0 +1,44 @@
<%# locals: (form:) %>
<% condition = form.object %>
<% rule = condition.rule %>
<li data-controller="rule--conditions element-removal" class="border border-alpha-black-100 rounded-md p-4 space-y-3">
<%= form.hidden_field :condition_type, value: "compound" %>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<% unless form.index == 0 %>
<div class="pl-2">
<span class="font-medium uppercase text-xs">and</span>
</div>
<% end %>
<p class="text-sm text-secondary">match</p>
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
<p class="text-sm text-secondary">of the following conditions</p>
</div>
<button type="button" data-action="element-removal#remove">
<%= icon("trash-2", color: "gray", size: "sm") %>
</button>
</div>
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
<template data-rule--conditions-target="subConditionTemplate">
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
<%= render "rule/conditions/condition", form: scf %>
<% end %>
</template>
<ul data-rule--conditions-target="subConditionsList" class="space-y-3">
<%= form.fields_for :sub_conditions do |scf| %>
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
<% end %>
</ul>
<button type="button" class="btn btn--ghost" data-action="rule--conditions#addSubCondition">
<%= icon("plus", color: "gray", size: "sm") %>
<span>Add condition</span>
</button>
</li>

View file

@ -0,0 +1,20 @@
<%# locals: (cta:) %>
<% message = "Updated to #{cta[:category_name]}" %>
<% description = "You can create a rule to automatically categorize transactions like this one" %>
<%= render "shared/notifications/cta", message: message, description: description do %>
<%= form_with model: Current.user, url: rule_prompt_settings_user_path(Current.user), method: :patch do |f| %>
<div class="flex gap-2 items-center mb-3 -mt-1">
<%= f.check_box :rule_prompts_disabled, class: "checkbox checkbox--light" %>
<%= f.label :rule_prompts_disabled, "Don't show this again", class: "text-xs text-secondary" %>
</div>
<%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %>
<%= tag.div class:"flex gap-2 justify-end" do %>
<%= f.submit "Dismiss", class: "btn btn--secondary" %>
<%= tag.a "Create rule", href: new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]), class: "btn btn--primary", data: { turbo_frame: "modal" } %>
<% end %>
<% end %>
<% end %>

View file

@ -0,0 +1,98 @@
<%# locals: (rule:) %>
<%= styled_form_with model: rule, class: "space-y-4 w-[550px]",
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
<%= f.hidden_field :resource_type, value: rule.resource_type %>
<% if @rule.errors.any? %>
<%= render "shared/form_errors", model: @rule %>
<% end %>
<section class="space-y-4">
<h3 class="text-sm font-medium">If <%= rule.resource_type %></h3>
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
<template data-rules-target="conditionGroupTemplate">
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %>
<% end %>
</template>
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
<template data-rules-target="conditionTemplate">
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition", form: cf %>
<% end %>
</template>
<ul data-rules-target="conditionsList" class="space-y-3 mb-4">
<%= f.fields_for :conditions do |cf| %>
<% if cf.object.compound? %>
<%= render "rule/conditions/condition_group", form: cf %>
<% else %>
<%= render "rule/conditions/condition", form: cf %>
<% end %>
<% end %>
</ul>
<div class="flex items-center gap-2">
<button type="button" data-action="rules#addCondition" class="btn btn--ghost">
<%= icon("plus") %>
<span>Add condition</span>
</button>
<button type="button" data-action="rules#addConditionGroup" class="btn btn--ghost">
<%= icon("boxes") %>
<span>Add condition group</span>
</button>
</div>
</section>
<section class="space-y-4">
<h3 class="text-sm font-medium">Then</h3>
<%# Action template, used by Stimulus controller to add new actions dynamically %>
<template data-rules-target="actionTemplate">
<%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: "IDX_PLACEHOLDER" do |af| %>
<%= render "rule/actions/action", form: af %>
<% end %>
</template>
<ul data-rules-target="actionsList" class="space-y-3">
<%= f.fields_for :actions do |af| %>
<%= render "rule/actions/action", form: af %>
<% end %>
</ul>
<button
type="button"
data-action="rules#addAction"
class="btn btn--ghost">
<%= icon("plus") %>
<span>Add action</span>
</button>
</section>
<section class="space-y-4">
<h3 class="text-sm font-medium">Apply this</h3>
<div class="space-y-2">
<div class="flex items-center gap-2">
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
<%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s", class: "text-sm" %>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %>
<%= f.label :effective_date_enabled_true, "Starting from", class: "text-sm" %>
</div>
<%= f.date_field :effective_date, container_class: "w-fit", data: { rules_target: "effectiveDateInput" } %>
</div>
</div>
</section>
<%= f.submit %>
<% end %>

View file

@ -0,0 +1,41 @@
<%# locals: (rule:) %>
<div class="flex justify-between items-center gap-4 bg-white shadow-border-xs rounded-md p-4">
<div class="text-sm space-y-1.5">
<p class="flex items-center flex-wrap gap-1.5">
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
<%= rule.actions.first.executor.label %>
</span>
<% if rule.actions.count > 1 %>
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
<% end %>
</p>
<p class="flex items-center flex-wrap gap-1.5">
<% if rule.effective_date.nil? %>
To all past and future <%= rule.resource_type.pluralize %>
<% else %>
To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %>
<% end %>
</p>
</div>
<div class="flex items-center gap-4">
<%= render "shared/toggle_form", model: rule, attribute: :active %>
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
<%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %>
<%= contextual_menu_item "Re-apply rule", url: confirm_rule_path(rule), turbo_frame: "modal", icon: "refresh-cw" %>
<% turbo_confirm = {
title: "Delete rule",
body: "Are you sure you want to delete this rule? Data affected by this rule will no longer be automatically updated. This action cannot be undone.",
accept: "Delete rule",
} %>
<%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: turbo_confirm %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,20 @@
<%= modal(reload_on_close: true) do %>
<div class="space-y-4 p-4 max-w-[400px]">
<div>
<div class="flex justify-between mb-2 gap-4">
<h3 class="font-medium text-md">Confirm changes</h3>
<button data-action="mousedown->modal#close">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-secondary") %>
</button>
</div>
<div class="text-secondary text-sm">
<p>
You are about to apply this rule to
<span class="text-primary font-medium"><%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %></span>
that meet the specified rule criteria. Please confirm if you wish to proceed with this change.
</p>
</div>
</div>
<%= button_to "Confirm changes", apply_rule_path(@rule), class: "btn btn--primary w-full justify-center", data: { turbo_frame: "_top"} %>
</div>
<% end %>

Some files were not shown because too many files have changed in this diff Show more