1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Merge branch 'main' into fix/issue-2100-dark-theme-does-not-allow-text-to-be-visible-in-modal

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
This commit is contained in:
Zach Gollwitzer 2025-04-22 09:06:30 -04:00 committed by GitHub
commit 761f075416
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
248 changed files with 6278 additions and 1167 deletions

View file

@ -11,13 +11,11 @@ alwaysApply: true
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
## Prohibited actions
Do not under any circumstance do the following:
- Do not run `rails server` in your responses.
- Do not run `touch tmp/restart.txt`
- Do not run `rails credentials`
- Do not automatically run migrations
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
- Do not automatically run migrations

View file

@ -15,7 +15,7 @@ The codebase uses TailwindCSS v4.x (the newest version) with a custom design sys
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
- Example 1: use `text-primary` rather than `text-primary`
- Example 1: use `text-primary` rather than `text-white`
- Example 2: use `bg-container` rather than `bg-white`
- Example 3: use `border border-primary` rather than `border border-gray-200`
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so

View file

@ -28,13 +28,14 @@ gem "hotwire_combobox"
# Background Jobs
gem "sidekiq"
# Error logging
# Monitoring
gem "vernier"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq"
gem "logtail-rails"
gem "skylight"
# Active Storage
gem "aws-sdk-s3", "~> 1.177.0", require: false
@ -79,6 +80,7 @@ group :development do
gem "web-console"
gem "faker"
gem "benchmark-ips"
gem "foreman"
end
group :test do

View file

@ -179,6 +179,7 @@ GEM
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
foreman (0.88.1)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.2)
@ -472,6 +473,8 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
skylight (6.0.4)
activesupport (>= 5.2.0)
smart_properties (1.17.0)
sorbet-runtime (0.5.11953)
stimulus-rails (1.3.4)
@ -550,6 +553,7 @@ DEPENDENCIES
faraday
faraday-multipart
faraday-retry
foreman
hotwire-livereload
hotwire_combobox
i18n-tasks
@ -584,6 +588,7 @@ DEPENDENCIES
sentry-sidekiq
sidekiq
simplecov
skylight
stimulus-rails
stripe
tailwindcss-rails

View file

@ -0,0 +1,37 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25"
class="gradient-fill"
fill="url(#paint0_linear_2046_1939)" />
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25" stroke="currentColor" stroke-width="1.5" />
<path
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
fill="currentColor" />
<path
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
fill="currentColor" />
<path
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
fill="currentColor" />
<path
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
<path
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
<path
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
<style>
[data-theme=dark] .gradient-fill {
fill: transparent;
}
</style>
<defs>
<linearGradient id="paint0_linear_2046_1939" x1="10" y1="6.25" x2="10" y2="20"
gradientUnits="userSpaceOnUse">
<stop stop-color="white" />
<stop offset="0.3" stop-color="#F7F7F7" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 7.5H17.5M2.5 12.5H17.5M7.5 7.5V17.5M12.5 7.5V17.5M4.16667 2.5H15.8333C16.7538 2.5 17.5 3.24619 17.5 4.16667V15.8333C17.5 16.7538 16.7538 17.5 15.8333 17.5H4.16667C3.24619 17.5 2.5 16.7538 2.5 15.8333V4.16667C2.5 3.24619 3.24619 2.5 4.16667 2.5Z"
stroke="#737373" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 468 B

View file

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

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;
}
@ -541,10 +553,18 @@
}
.btn--ghost {
@apply border border-transparent text-primary hover:button-bg-ghost-hover;
@apply border border-transparent hover:button-bg-ghost-hover;
@variant theme-dark {
@apply fg-primary hover:button-bg-ghost-hover;
@apply hover:fg-inverse hover:button-bg-ghost-hover;
}
}
.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;
}
}
@ -725,7 +745,7 @@
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
@apply bg-gray-800 fg-inverse;
}
}
@ -760,4 +780,12 @@
@variant theme-dark {
@apply bg-alpha-black-700;
}
}
@utility bg-nav-indicator {
@apply bg-black;
@variant theme-dark {
@apply bg-white;
}
}

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

@ -36,7 +36,9 @@ class Import::ConfigurationsController < ApplicationController
:currency_col_label,
:date_format,
:number_format,
:signage_convention
:signage_convention,
:amount_type_strategy,
:amount_type_inflow_value,
)
end
end

View file

@ -6,6 +6,13 @@ class Import::UploadsController < ApplicationController
def show
end
def sample_csv
send_data @import.csv_template.to_csv,
filename: "#{@import.type.underscore.split('_').first}_sample.csv",
type: "text/csv",
disposition: "attachment"
end
def update
if csv_valid?(csv_str)
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))

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

@ -6,6 +6,7 @@ class RegistrationsController < ApplicationController
before_action :set_user, only: :create
before_action :set_invitation
before_action :claim_invite_code, only: :create, if: :invite_code_required?
before_action :validate_password_requirements, only: :create
def new
@user = User.new(email: @invitation&.email)
@ -53,4 +54,29 @@ class RegistrationsController < ApplicationController
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
end
end
def validate_password_requirements
password = user_params[:password]
return if password.blank? # Let Rails built-in validations handle blank passwords
if password.length < 8
@user.errors.add(:password, "must be at least 8 characters")
end
unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
@user.errors.add(:password, "must include both uppercase and lowercase letters")
end
unless password.match?(/\d/)
@user.errors.add(:password, "must include at least one number")
end
unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
@user.errors.add(:password, "must include at least one special character")
end
if @user.errors.present?
render :new, status: :unprocessable_entity
end
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

@ -23,9 +23,11 @@ class Settings::ProfilesController < ApplicationController
end
if @user.destroy
flash[:notice] = t("settings.profiles.destroy.member_removed")
# Also destroy the invitation associated with this user for this family
Current.family.invitations.find_by(email: @user.email)&.destroy
flash[:notice] = "Member removed successfully."
else
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
flash[:alert] = "Failed to remove member."
end
redirect_to settings_profile_path

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

@ -5,6 +5,10 @@ module ApplicationHelper
render partial: "shared/icon", locals: { key:, size:, color: }
end
def icon_custom(key, size: "md", color: "current")
render partial: "shared/icon_custom", locals: { key:, size:, color: }
end
# Convert alpha (0-1) to 8-digit hex (00-FF)
def hex_with_alpha(hex, alpha)
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
@ -23,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
#
@ -49,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,15 +4,15 @@ 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)
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false, class: nil)
form.label name, for: form.field_id(name, value), class: "group has-disabled:cursor-not-allowed" do
concat radio_tab_contents(label:, icon:)
concat radio_tab_contents(label:, icon:, class:)
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
end
end
@ -29,8 +29,8 @@ end
end
private
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
def radio_tab_contents(label:, icon:, class: nil)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm md:text-normal text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
concat lucide_icon(icon, class: "w-5 h-5")
concat tag.span(label, class: "group-has-checked:font-semibold")
end

View file

@ -55,6 +55,11 @@ module ImportsHelper
[ base, border ].join(" ")
end
def cell_is_valid?(row, field)
row.valid? # populate errors
!row.errors.key?(field)
end
private
def permitted_import_types
%w[transaction_import trade_import account_import mint_import]

View file

@ -154,200 +154,200 @@ module LanguagesHelper
].freeze
COUNTRY_MAPPING = {
AF: "Afghanistan",
AL: "Albania",
DZ: "Algeria",
AD: "Andorra",
AO: "Angola",
AG: "Antigua and Barbuda",
AR: "Argentina",
AM: "Armenia",
AU: "Australia",
AT: "Austria",
AZ: "Azerbaijan",
BS: "Bahamas",
BH: "Bahrain",
BD: "Bangladesh",
BB: "Barbados",
BY: "Belarus",
BE: "Belgium",
BZ: "Belize",
BJ: "Benin",
BT: "Bhutan",
BO: "Bolivia",
BA: "Bosnia and Herzegovina",
BW: "Botswana",
BR: "Brazil",
BN: "Brunei",
BG: "Bulgaria",
BF: "Burkina Faso",
BI: "Burundi",
KH: "Cambodia",
CM: "Cameroon",
CA: "Canada",
CV: "Cape Verde",
CF: "Central African Republic",
TD: "Chad",
CL: "Chile",
CN: "China",
CO: "Colombia",
KM: "Comoros",
CG: "Congo",
CD: "Congo, Democratic Republic of the",
CR: "Costa Rica",
CI: "Côte d'Ivoire",
HR: "Croatia",
CU: "Cuba",
CY: "Cyprus",
CZ: "Czech Republic",
DK: "Denmark",
DJ: "Djibouti",
DM: "Dominica",
DO: "Dominican Republic",
EC: "Ecuador",
EG: "Egypt",
SV: "El Salvador",
GQ: "Equatorial Guinea",
ER: "Eritrea",
EE: "Estonia",
ET: "Ethiopia",
FJ: "Fiji",
FI: "Finland",
FR: "France",
GA: "Gabon",
GM: "Gambia",
GE: "Georgia",
DE: "Germany",
GH: "Ghana",
GR: "Greece",
GD: "Grenada",
GT: "Guatemala",
GN: "Guinea",
GW: "Guinea-Bissau",
GY: "Guyana",
HT: "Haiti",
HN: "Honduras",
HU: "Hungary",
IS: "Iceland",
IN: "India",
ID: "Indonesia",
IR: "Iran",
IQ: "Iraq",
IE: "Ireland",
IL: "Israel",
IT: "Italy",
JM: "Jamaica",
JP: "Japan",
JO: "Jordan",
KZ: "Kazakhstan",
KE: "Kenya",
KI: "Kiribati",
KP: "North Korea",
KR: "South Korea",
KW: "Kuwait",
KG: "Kyrgyzstan",
LA: "Laos",
LV: "Latvia",
LB: "Lebanon",
LS: "Lesotho",
LR: "Liberia",
LY: "Libya",
LI: "Liechtenstein",
LT: "Lithuania",
LU: "Luxembourg",
MK: "North Macedonia",
MG: "Madagascar",
MW: "Malawi",
MY: "Malaysia",
MV: "Maldives",
ML: "Mali",
MT: "Malta",
MH: "Marshall Islands",
MR: "Mauritania",
MU: "Mauritius",
MX: "Mexico",
FM: "Micronesia",
MD: "Moldova",
MC: "Monaco",
MN: "Mongolia",
ME: "Montenegro",
MA: "Morocco",
MZ: "Mozambique",
MM: "Myanmar",
NA: "Namibia",
NR: "Nauru",
NP: "Nepal",
NL: "Netherlands",
NZ: "New Zealand",
NI: "Nicaragua",
NE: "Niger",
NG: "Nigeria",
NO: "Norway",
OM: "Oman",
PK: "Pakistan",
PW: "Palau",
PA: "Panama",
PG: "Papua New Guinea",
PY: "Paraguay",
PE: "Peru",
PH: "Philippines",
PL: "Poland",
PT: "Portugal",
QA: "Qatar",
RO: "Romania",
RU: "Russia",
RW: "Rwanda",
KN: "Saint Kitts and Nevis",
LC: "Saint Lucia",
VC: "Saint Vincent and the Grenadines",
WS: "Samoa",
SM: "San Marino",
ST: "Sao Tome and Principe",
SA: "Saudi Arabia",
SN: "Senegal",
RS: "Serbia",
SC: "Seychelles",
SL: "Sierra Leone",
SG: "Singapore",
SK: "Slovakia",
SI: "Slovenia",
SB: "Solomon Islands",
SO: "Somalia",
ZA: "South Africa",
SS: "South Sudan",
ES: "Spain",
LK: "Sri Lanka",
SD: "Sudan",
SR: "Suriname",
SE: "Sweden",
CH: "Switzerland",
SY: "Syria",
TW: "Taiwan",
TJ: "Tajikistan",
TZ: "Tanzania",
TH: "Thailand",
TL: "Timor-Leste",
TG: "Togo",
TO: "Tonga",
TT: "Trinidad and Tobago",
TN: "Tunisia",
TR: "Turkey",
TM: "Turkmenistan",
TV: "Tuvalu",
UG: "Uganda",
UA: "Ukraine",
AE: "United Arab Emirates",
GB: "United Kingdom",
US: "United States",
UY: "Uruguay",
UZ: "Uzbekistan",
VU: "Vanuatu",
VA: "Vatican City",
VE: "Venezuela",
VN: "Vietnam",
YE: "Yemen",
ZM: "Zambia",
ZW: "Zimbabwe"
AF: "🇦🇫 Afghanistan",
AL: "🇦🇱 Albania",
DZ: "🇩🇿 Algeria",
AD: "🇦🇩 Andorra",
AO: "🇦🇴 Angola",
AG: "🇦🇬 Antigua and Barbuda",
AR: "🇦🇷 Argentina",
AM: "🇦🇲 Armenia",
AU: "🇦🇺 Australia",
AT: "🇦🇹 Austria",
AZ: "🇦🇿 Azerbaijan",
BS: "🇧🇸 Bahamas",
BH: "🇧🇭 Bahrain",
BD: "🇧🇩 Bangladesh",
BB: "🇧🇧 Barbados",
BY: "🇧🇾 Belarus",
BE: "🇧🇪 Belgium",
BZ: "🇧🇿 Belize",
BJ: "🇧🇯 Benin",
BT: "🇧🇹 Bhutan",
BO: "🇧🇴 Bolivia",
BA: "🇧🇦 Bosnia and Herzegovina",
BW: "🇧🇼 Botswana",
BR: "🇧🇷 Brazil",
BN: "🇧🇳 Brunei",
BG: "🇧🇬 Bulgaria",
BF: "🇧🇫 Burkina Faso",
BI: "🇧🇮 Burundi",
KH: "🇰🇭 Cambodia",
CM: "🇨🇲 Cameroon",
CA: "🇨🇦 Canada",
CV: "🇨🇻 Cape Verde",
CF: "🇨🇫 Central African Republic",
TD: "🇹🇩 Chad",
CL: "🇨🇱 Chile",
CN: "🇨🇳 China",
CO: "🇨🇴 Colombia",
KM: "🇰🇲 Comoros",
CG: "🇨🇬 Congo",
CD: "🇨🇩 Congo, Democratic Republic of the",
CR: "🇨🇷 Costa Rica",
CI: "🇨🇮 Côte d'Ivoire",
HR: "🇭🇷 Croatia",
CU: "🇨🇺 Cuba",
CY: "🇨🇾 Cyprus",
CZ: "🇨🇿 Czech Republic",
DK: "🇩🇰 Denmark",
DJ: "🇩🇯 Djibouti",
DM: "🇩🇲 Dominica",
DO: "🇩🇴 Dominican Republic",
EC: "🇪🇨 Ecuador",
EG: "🇪🇬 Egypt",
SV: "🇸🇻 El Salvador",
GQ: "🇬🇶 Equatorial Guinea",
ER: "🇪🇷 Eritrea",
EE: "🇪🇪 Estonia",
ET: "🇪🇹 Ethiopia",
FJ: "🇫🇯 Fiji",
FI: "🇫🇮 Finland",
FR: "🇫🇷 France",
GA: "🇬🇦 Gabon",
GM: "🇬🇲 Gambia",
GE: "🇬🇪 Georgia",
DE: "🇩🇪 Germany",
GH: "🇬🇭 Ghana",
GR: "🇬🇷 Greece",
GD: "🇬🇩 Grenada",
GT: "🇬🇹 Guatemala",
GN: "🇬🇳 Guinea",
GW: "🇬🇼 Guinea-Bissau",
GY: "🇬🇾 Guyana",
HT: "🇭🇹 Haiti",
HN: "🇭🇳 Honduras",
HU: "🇭🇺 Hungary",
IS: "🇮🇸 Iceland",
IN: "🇮🇳 India",
ID: "🇮🇩 Indonesia",
IR: "🇮🇷 Iran",
IQ: "🇮🇶 Iraq",
IE: "🇮🇪 Ireland",
IL: "🇮🇱 Israel",
IT: "🇮🇹 Italy",
JM: "🇯🇲 Jamaica",
JP: "🇯🇵 Japan",
JO: "🇯🇴 Jordan",
KZ: "🇰🇿 Kazakhstan",
KE: "🇰🇪 Kenya",
KI: "🇰🇮 Kiribati",
KP: "🇰🇵 North Korea",
KR: "🇰🇷 South Korea",
KW: "🇰🇼 Kuwait",
KG: "🇰🇬 Kyrgyzstan",
LA: "🇱🇦 Laos",
LV: "🇱🇻 Latvia",
LB: "🇱🇧 Lebanon",
LS: "🇱🇸 Lesotho",
LR: "🇱🇷 Liberia",
LY: "🇱🇾 Libya",
LI: "🇱🇮 Liechtenstein",
LT: "🇱🇹 Lithuania",
LU: "🇱🇺 Luxembourg",
MK: "🇲🇰 North Macedonia",
MG: "🇲🇬 Madagascar",
MW: "🇲🇼 Malawi",
MY: "🇲🇾 Malaysia",
MV: "🇲🇻 Maldives",
ML: "🇲🇱 Mali",
MT: "🇲🇹 Malta",
MH: "🇲🇭 Marshall Islands",
MR: "🇲🇷 Mauritania",
MU: "🇲🇺 Mauritius",
MX: "🇲🇽 Mexico",
FM: "🇫🇲 Micronesia",
MD: "🇲🇩 Moldova",
MC: "🇲🇨 Monaco",
MN: "🇲🇳 Mongolia",
ME: "🇲🇪 Montenegro",
MA: "🇲🇦 Morocco",
MZ: "🇲🇿 Mozambique",
MM: "🇲🇲 Myanmar",
NA: "🇳🇦 Namibia",
NR: "🇳🇷 Nauru",
NP: "🇳🇵 Nepal",
NL: "🇳🇱 Netherlands",
NZ: "🇳🇿 New Zealand",
NI: "🇳🇮 Nicaragua",
NE: "🇳🇪 Niger",
NG: "🇳🇬 Nigeria",
NO: "🇳🇴 Norway",
OM: "🇴🇲 Oman",
PK: "🇵🇰 Pakistan",
PW: "🇵🇼 Palau",
PA: "🇵🇦 Panama",
PG: "🇵🇬 Papua New Guinea",
PY: "🇵🇾 Paraguay",
PE: "🇵🇪 Peru",
PH: "🇵🇭 Philippines",
PL: "🇵🇱 Poland",
PT: "🇵🇹 Portugal",
QA: "🇶🇦 Qatar",
RO: "🇷🇴 Romania",
RU: "🇷🇺 Russia",
RW: "🇷🇼 Rwanda",
KN: "🇰🇳 Saint Kitts and Nevis",
LC: "🇱🇨 Saint Lucia",
VC: "🇻🇨 Saint Vincent and the Grenadines",
WS: "🇼🇸 Samoa",
SM: "🇸🇲 San Marino",
ST: "🇸🇹 Sao Tome and Principe",
SA: "🇸🇦 Saudi Arabia",
SN: "🇸🇳 Senegal",
RS: "🇷🇸 Serbia",
SC: "🇸🇨 Seychelles",
SL: "🇸🇱 Sierra Leone",
SG: "🇸🇬 Singapore",
SK: "🇸🇰 Slovakia",
SI: "🇸🇮 Slovenia",
SB: "🇸🇧 Solomon Islands",
SO: "🇸🇴 Somalia",
ZA: "🇿🇦 South Africa",
SS: "🇸🇸 South Sudan",
ES: "🇪🇸 Spain",
LK: "🇱🇰 Sri Lanka",
SD: "🇸🇩 Sudan",
SR: "🇸🇷 Suriname",
SE: "🇸🇪 Sweden",
CH: "🇨🇭 Switzerland",
SY: "🇸🇾 Syria",
TW: "🇹🇼 Taiwan",
TJ: "🇹🇯 Tajikistan",
TZ: "🇹🇿 Tanzania",
TH: "🇹🇭 Thailand",
TL: "🇹🇱 Timor-Leste",
TG: "🇹🇬 Togo",
TO: "🇹🇴 Tonga",
TT: "🇹🇹 Trinidad and Tobago",
TN: "🇹🇳 Tunisia",
TR: "🇹🇷 Turkey",
TM: "🇹🇲 Turkmenistan",
TV: "🇹🇻 Tuvalu",
UG: "🇺🇬 Uganda",
UA: "🇺🇦 Ukraine",
AE: "🇦🇪 United Arab Emirates",
GB: "🇬🇧 United Kingdom",
US: "🇺🇸 United States",
UY: "🇺🇾 Uruguay",
UZ: "🇺🇿 Uzbekistan",
VU: "🇻🇺 Vanuatu",
VA: "🇻🇦 Vatican City",
VE: "🇻🇪 Venezuela",
VN: "🇻🇳 Vietnam",
YE: "🇾🇪 Yemen",
ZM: "🇿🇲 Zambia",
ZW: "🇿🇼 Zimbabwe"
}.freeze
def country_options

View file

@ -6,8 +6,8 @@ module MenusHelper
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal, class_name: nil)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2 #{class_name}", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
@ -33,7 +33,8 @@ module MenusHelper
private
def contextual_menu_icon(icon)
tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
lucide_icon icon, class: "w-5 h-5 text-secondary"
concat lucide_icon("more-vertical", class: "w-5 h-5 text-secondary md:hidden")
concat lucide_icon(icon, class: "w-5 h-5 text-secondary hidden md:block")
end
end

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 }
]
@ -40,7 +41,17 @@ module SettingsHelper
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "flex justify-between gap-4" do
content_tag :div, class: "hidden md:flex flex-row justify-between gap-4" do
concat(previous_setting)
concat(next_setting)
end
end
def settings_nav_footer_mobile
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "md:hidden flex flex-col gap-4" do
concat(previous_setting)
concat(next_setting)
end

View file

@ -51,7 +51,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def submit(value = nil, options = {})
default_options = {
data: { turbo_submits_with: "Submitting..." },
class: "btn btn--primary w-full"
class: "btn btn--primary w-full justify-center"
}
merged_options = default_options.merge(options)

View file

@ -0,0 +1,74 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "fileName", "uploadArea", "uploadText"]
connect() {
if (this.hasInputTarget) {
this.inputTarget.addEventListener("change", this.fileSelected.bind(this))
}
// Find the form element
this.form = this.element.closest("form")
if (this.form) {
this.form.addEventListener("turbo:submit-start", this.formSubmitting.bind(this))
}
}
disconnect() {
if (this.hasInputTarget) {
this.inputTarget.removeEventListener("change", this.fileSelected.bind(this))
}
if (this.form) {
this.form.removeEventListener("turbo:submit-start", this.formSubmitting.bind(this))
}
}
triggerFileInput() {
if (this.hasInputTarget) {
this.inputTarget.click()
}
}
fileSelected() {
if (this.hasInputTarget && this.inputTarget.files.length > 0) {
const fileName = this.inputTarget.files[0].name
if (this.hasFileNameTarget) {
// Find the paragraph element inside the fileName target
const fileNameText = this.fileNameTarget.querySelector('p')
if (fileNameText) {
fileNameText.textContent = fileName
}
this.fileNameTarget.classList.remove("hidden")
}
if (this.hasUploadTextTarget) {
this.uploadTextTarget.classList.add("hidden")
}
}
}
formSubmitting() {
if (this.hasFileNameTarget && this.hasInputTarget && this.inputTarget.files.length > 0) {
const fileNameText = this.fileNameTarget.querySelector('p')
if (fileNameText) {
fileNameText.textContent = `Uploading ${this.inputTarget.files[0].name}...`
}
// Change the icon to a loader
const iconContainer = this.fileNameTarget.querySelector('.lucide-file-text')
if (iconContainer) {
iconContainer.classList.add('animate-pulse')
}
}
if (this.hasUploadAreaTarget) {
this.uploadAreaTarget.classList.add("opacity-70")
}
}
}

View file

@ -0,0 +1,118 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="import"
export default class extends Controller {
static values = {
csv: { type: Array, default: [] },
amountTypeColumnKey: { type: String, default: "" },
};
static targets = [
"signedAmountFieldset",
"customColumnFieldset",
"amountTypeValue",
"amountTypeStrategySelect",
];
connect() {
if (
this.amountTypeStrategySelectTarget.value === "custom_column" &&
this.amountTypeColumnKeyValue
) {
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
}
}
handleAmountTypeStrategyChange(event) {
const amountTypeStrategy = event.target.value;
if (amountTypeStrategy === "custom_column") {
this.#enableCustomColumnFieldset();
if (this.amountTypeColumnKeyValue) {
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
}
}
if (amountTypeStrategy === "signed_amount") {
this.#enableSignedAmountFieldset();
}
}
handleAmountTypeChange(event) {
const amountTypeColumnKey = event.target.value;
this.#showAmountTypeValueTargets(amountTypeColumnKey);
}
#showAmountTypeValueTargets(amountTypeColumnKey) {
const selectableValues = this.#uniqueValuesForColumn(amountTypeColumnKey);
this.amountTypeValueTarget.classList.remove("hidden");
this.amountTypeValueTarget.classList.add("flex");
const select = this.amountTypeValueTarget.querySelector("select");
const currentValue = select.value;
select.options.length = 0;
const fragment = document.createDocumentFragment();
// Only add the prompt if there's no current value
if (!currentValue) {
fragment.appendChild(new Option("Select value", ""));
}
selectableValues.forEach((value) => {
const option = new Option(value, value);
if (value === currentValue) {
option.selected = true;
}
fragment.appendChild(option);
});
select.appendChild(fragment);
}
#uniqueValuesForColumn(column) {
const colIdx = this.csvValue[0].indexOf(column);
const values = this.csvValue.slice(1).map((row) => row[colIdx]);
return [...new Set(values)];
}
#enableCustomColumnFieldset() {
this.customColumnFieldsetTarget.classList.remove("hidden");
this.signedAmountFieldsetTarget.classList.add("hidden");
// Set required on custom column fields
this.customColumnFieldsetTarget
.querySelectorAll("select, input")
.forEach((field) => {
field.setAttribute("required", "");
});
// Remove required from signed amount fields
this.signedAmountFieldsetTarget
.querySelectorAll("select, input")
.forEach((field) => {
field.removeAttribute("required");
});
}
#enableSignedAmountFieldset() {
this.customColumnFieldsetTarget.classList.add("hidden");
this.signedAmountFieldsetTarget.classList.remove("hidden");
// Remove required from custom column fields
this.customColumnFieldsetTarget
.querySelectorAll("select, input")
.forEach((field) => {
field.removeAttribute("required");
});
// Set required on signed amount fields
this.signedAmountFieldsetTarget
.querySelectorAll("select, input")
.forEach((field) => {
field.setAttribute("required", "");
});
}
}

View file

@ -0,0 +1,149 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="mobile-cell-interaction"
export default class extends Controller {
static targets = ["field", "highlight", "errorTooltip", "errorIcon"];
static values = { error: String };
touchTimeout = null;
activeTooltip = null;
documentClickHandler = null;
connect() {
this.documentClickHandler = this.handleDocumentClick.bind(this);
document.addEventListener('click', this.documentClickHandler);
}
disconnect() {
if (this.documentClickHandler) {
document.removeEventListener('click', this.documentClickHandler);
}
}
handleDocumentClick(event) {
if (event.target.closest('[data-mobile-cell-interaction-target="errorTooltip"]') ||
event.target.closest('[data-mobile-cell-interaction-target="errorIcon"]')) {
return;
}
this.hideAllErrorTooltips();
}
highlightCell(event) {
const field = event.target;
const highlight = this.findHighlightForField(field);
if (highlight) {
highlight.style.opacity = '1';
}
}
unhighlightCell(event) {
const field = event.target;
const highlight = this.findHighlightForField(field);
if (highlight) {
highlight.style.opacity = '0';
}
this.hideAllErrorTooltips();
}
handleCellTouch(event) {
if (this.touchTimeout) {
clearTimeout(this.touchTimeout);
}
const field = event.target;
const highlight = this.findHighlightForField(field);
if (highlight) {
highlight.style.opacity = '1';
this.touchTimeout = window.setTimeout(() => {
if (document.activeElement !== field) {
highlight.style.opacity = '0';
}
}, 1000);
}
if (this.hasErrorValue && this.errorValue) {
this.showErrorTooltip();
}
}
toggleErrorMessage(event) {
const errorIcon = event.currentTarget;
const cellContainer = errorIcon.closest('div');
const field = cellContainer.querySelector('input');
if (field) {
field.focus();
}
const tooltip = this.errorTooltipTarget;
this.hideAllTooltipsExcept(tooltip);
if (tooltip.classList.contains('hidden')) {
tooltip.classList.remove('hidden');
this.activeTooltip = tooltip;
setTimeout(() => {
if (tooltip === this.activeTooltip) {
tooltip.classList.add('hidden');
this.activeTooltip = null;
}
}, 3000);
} else {
tooltip.classList.add('hidden');
this.activeTooltip = null;
}
event.stopPropagation();
}
showErrorTooltip() {
if (this.hasErrorTooltipTarget) {
const tooltip = this.errorTooltipTarget;
tooltip.classList.remove('hidden');
this.activeTooltip = tooltip;
setTimeout(() => {
if (tooltip === this.activeTooltip) {
tooltip.classList.add('hidden');
this.activeTooltip = null;
}
}, 3000);
}
}
hideAllErrorTooltips() {
document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => {
tooltip.classList.add('hidden');
});
this.activeTooltip = null;
}
hideAllTooltipsExcept(tooltipToKeep) {
document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => {
if (tooltip !== tooltipToKeep) {
tooltip.classList.add('hidden');
}
});
}
selectCell(event) {
const errorIcon = event.currentTarget;
const cellContainer = errorIcon.closest('div');
const field = cellContainer.querySelector('input');
if (field) {
field.focus();
event.stopPropagation();
}
}
findHighlightForField(field) {
const container = field.closest('div');
return container ? container.querySelector('[data-mobile-cell-interaction-target="highlight"]') : null;
}
}

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,63 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="password-validator"
export default class extends Controller {
static targets = ["input", "requirementType", "blockLine"];
connect() {
this.validate();
}
validate() {
const password = this.inputTarget.value;
let requirementsMet = 0;
// Check each requirement and count how many are met
const lengthValid = password.length >= 8;
const caseValid = /[A-Z]/.test(password) && /[a-z]/.test(password);
const numberValid = /\d/.test(password);
const specialValid = /[!@#$%^&*(),.?":{}|<>]/.test(password);
// Update individual requirement text
this.validateRequirementText("length", lengthValid);
this.validateRequirementText("case", caseValid);
this.validateRequirementText("number", numberValid);
this.validateRequirementText("special", specialValid);
// Count total requirements met
if (lengthValid) requirementsMet++;
if (caseValid) requirementsMet++;
if (numberValid) requirementsMet++;
if (specialValid) requirementsMet++;
// Update block lines sequentially
this.updateBlockLines(requirementsMet);
}
validateRequirementText(type, isValid) {
this.requirementTypeTargets.forEach((target) => {
if (target.dataset.requirementType === type) {
if (isValid) {
target.classList.remove("text-secondary");
target.classList.add("text-green-600");
} else {
target.classList.remove("text-green-600");
target.classList.add("text-secondary");
}
}
});
}
updateBlockLines(requirementsMet) {
// Update block lines sequentially based on total requirements met
this.blockLineTargets.forEach((line, index) => {
if (index < requirementsMet) {
line.classList.remove("bg-gray-200");
line.classList.add("bg-green-600");
} else {
line.classList.remove("bg-green-600");
line.classList.add("bg-gray-200");
}
});
}
}

View file

@ -0,0 +1,19 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="password-visibility"
export default class extends Controller {
static targets = ["input", "showIcon", "hideIcon"];
connect() {
this.hideIconTarget.classList.add("hidden");
}
toggle() {
const input = this.inputTarget;
const type = input.type === "password" ? "text" : "password";
input.type = type;
this.showIconTarget.classList.toggle("hidden");
this.hideIconTarget.classList.toggle("hidden");
}
}

View file

@ -0,0 +1,39 @@
/*
https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i
modified to add support for horizontal scrolling
*/
if (!window.scrollPositions) {
window.scrollPositions = {};
}
function preserveScroll() {
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
scrollPositions[element.id] = {
top: element.scrollTop,
left: element.scrollLeft
};
});
}
function restoreScroll(event) {
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
if (scrollPositions[element.id]) {
element.scrollTop = scrollPositions[element.id].top;
element.scrollLeft = scrollPositions[element.id].left;
}
});
if (!event.detail.newBody) return;
// event.detail.newBody is the body element to be swapped in.
// https://turbo.hotwired.dev/reference/events
event.detail.newBody.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
if (scrollPositions[element.id]) {
element.scrollTop = scrollPositions[element.id].top;
element.scrollLeft = scrollPositions[element.id].left;
}
});
}
window.addEventListener("turbo:before-cache", preserveScroll);
window.addEventListener("turbo:before-render", restoreScroll);
window.addEventListener("turbo:render", restoreScroll);

View file

@ -8,6 +8,9 @@ export default class extends Controller {
"deleteProfileImage",
"input",
"clearBtn",
"uploadText",
"changeText",
"cameraIcon"
];
clearFileInput() {
@ -17,6 +20,12 @@ export default class extends Controller {
this.attachedImageTarget.classList.add("hidden");
this.previewImageTarget.classList.add("hidden");
this.deleteProfileImageTarget.value = "1";
this.uploadTextTarget.classList.remove("hidden");
this.changeTextTarget.classList.add("hidden");
this.changeTextTarget.setAttribute("aria-hidden", "true");
this.uploadTextTarget.setAttribute("aria-hidden", "false");
this.cameraIconTarget.classList.remove("!hidden");
}
showFileInputPreview(event) {
@ -28,7 +37,11 @@ export default class extends Controller {
this.previewImageTarget.classList.remove("hidden");
this.clearBtnTarget.classList.remove("hidden");
this.deleteProfileImageTarget.value = "0";
this.uploadTextTarget.classList.add("hidden");
this.changeTextTarget.classList.remove("hidden");
this.changeTextTarget.setAttribute("aria-hidden", "false");
this.uploadTextTarget.setAttribute("aria-hidden", "true");
this.cameraIconTarget.classList.add("!hidden");
this.previewImageTarget.querySelector("img").src =
URL.createObjectURL(file);
}

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

@ -2,7 +2,7 @@ class Address < ApplicationRecord
belongs_to :addressable, polymorphic: true
def to_s
I18n.t("address.format",
string = I18n.t("address.format",
line1: line1,
line2: line2,
county: county,
@ -11,5 +11,8 @@ class Address < ApplicationRecord
country: country,
postal_code: postal_code
)
# Clean up the string to maintain I18n comma formatting
string.split(",").map(&:strip).reject(&:empty?).join(", ")
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

@ -10,6 +10,8 @@ class Import < ApplicationRecord
"1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY
}.freeze
AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze
belongs_to :family
belongs_to :account, optional: true
@ -27,8 +29,9 @@ class Import < ApplicationRecord
}, validate: true, default: "pending"
validates :type, inclusion: { in: TYPES }
validates :amount_type_strategy, inclusion: { in: AMOUNT_TYPE_STRATEGIES }
validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true
validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }
has_many :rows, dependent: :destroy

View file

@ -44,7 +44,19 @@ class Import::Row < ApplicationRecord
# In the Maybe system, positive amounts == "outflows", so we must reverse signage
def apply_transaction_signage_convention(value)
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
if import.amount_type_strategy == "signed_amount"
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
elsif import.amount_type_strategy == "custom_column"
inflow_value = import.amount_type_inflow_value
if entity_type == inflow_value
value * -1
else
value
end
else
raise "Unknown amount type strategy for import: #{import.amount_type_strategy}"
end
end
def required_columns

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

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

@ -0,0 +1,29 @@
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 value_display
if value.present?
if options
options.find { |option| option.last == value }&.first
else
""
end
else
""
end
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,74 @@
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 value_display
if value.present?
if options
options.find { |option| option.last == value }&.first
else
value
end
else
""
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

@ -1,4 +1,6 @@
class Sync < ApplicationRecord
Error = Class.new(StandardError)
belongs_to :syncable, polymorphic: true
belongs_to :parent, class_name: "Sync", optional: true
@ -21,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
@ -41,7 +43,7 @@ class Sync < ApplicationRecord
def handle_child_completion_event
unless has_pending_child_syncs?
if has_failed_child_syncs?
fail!("One or more child syncs failed")
fail!(Error.new("One or more child syncs failed"))
else
complete!
syncable.post_sync(self)

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

@ -31,6 +31,7 @@ class TradeImport < Import
),
)
end
Trade.import!(trades, recursive: true)
end
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

@ -48,6 +48,12 @@ class TransactionImport < Import
base
end
def selectable_amount_type_values
return [] if entity_type_col_label.nil?
csv_rows.map { |row| row[entity_type_col_label] }.uniq
end
def csv_template
template = <<-CSV
date*,amount*,name,currency,category,tags,account,notes

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"

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