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:
commit
761f075416
248 changed files with 6278 additions and 1167 deletions
|
@ -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
|
|
@ -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
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
37
app/assets/images/icon-assistant.svg
Normal file
37
app/assets/images/icon-assistant.svg
Normal 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 |
5
app/assets/images/icon-csv.svg
Normal file
5
app/assets/images/icon-csv.svg
Normal 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 |
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -31,7 +31,7 @@ class AccountsController < ApplicationController
|
|||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
redirect_back_or_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
58
app/controllers/concerns/notifiable.rb
Normal file
58
app/controllers/concerns/notifiable.rb
Normal 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
|
54
app/controllers/family_merchants_controller.rb
Normal file
54
app/controllers/family_merchants_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
77
app/controllers/rules_controller.rb
Normal file
77
app/controllers/rules_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: [] } ]
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
##
|
||||
|
|
|
@ -34,7 +34,7 @@ module EntriesHelper
|
|||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
entry.name
|
||||
].join(" • ")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
74
app/javascript/controllers/file_upload_controller.js
Normal file
74
app/javascript/controllers/file_upload_controller.js
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
118
app/javascript/controllers/import_controller.js
Normal file
118
app/javascript/controllers/import_controller.js
Normal 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", "");
|
||||
});
|
||||
}
|
||||
}
|
149
app/javascript/controllers/mobile_cell_interaction_controller.js
Normal file
149
app/javascript/controllers/mobile_cell_interaction_controller.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ export default class extends Controller {
|
|||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
window.location.reload();
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
63
app/javascript/controllers/password_validator_controller.js
Normal file
63
app/javascript/controllers/password_validator_controller.js
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
19
app/javascript/controllers/password_visibility_controller.js
Normal file
19
app/javascript/controllers/password_visibility_controller.js
Normal 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");
|
||||
}
|
||||
}
|
39
app/javascript/controllers/preserve_scroll_controller.js
Normal file
39
app/javascript/controllers/preserve_scroll_controller.js
Normal 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);
|
|
@ -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);
|
||||
}
|
||||
|
|
55
app/javascript/controllers/rule/actions_controller.js
Normal file
55
app/javascript/controllers/rule/actions_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
104
app/javascript/controllers/rule/conditions_controller.js
Normal file
104
app/javascript/controllers/rule/conditions_controller.js
Normal 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);
|
||||
}
|
||||
}
|
48
app/javascript/controllers/rules_controller.js
Normal file
48
app/javascript/controllers/rules_controller.js
Normal 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();
|
||||
}
|
||||
}
|
7
app/jobs/auto_categorize_job.rb
Normal file
7
app/jobs/auto_categorize_job.rb
Normal 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
|
7
app/jobs/auto_detect_merchants_job.rb
Normal file
7
app/jobs/auto_detect_merchants_job.rb
Normal 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
|
|
@ -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
7
app/jobs/rule_job.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,6 +9,8 @@ module Accountable
|
|||
end
|
||||
|
||||
included do
|
||||
include Enrichable
|
||||
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
|
|
63
app/models/concerns/enrichable.rb
Normal file
63
app/models/concerns/enrichable.rb
Normal 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
|
5
app/models/data_enrichment.rb
Normal file
5
app/models/data_enrichment.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class DataEnrichment < ApplicationRecord
|
||||
belongs_to :enrichable, polymorphic: true
|
||||
|
||||
enum :source, { rule: "rule", plaid: "plaid", synth: "synth", ai: "ai" }
|
||||
end
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,6 +8,8 @@ module Entryable
|
|||
end
|
||||
|
||||
included do
|
||||
include Enrichable
|
||||
|
||||
has_one :entry, as: :entryable, touch: true
|
||||
|
||||
scope :with_entry, -> { joins(:entry) }
|
||||
|
|
|
@ -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
|
||||
|
|
88
app/models/family/auto_categorizer.rb
Normal file
88
app/models/family/auto_categorizer.rb
Normal 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
|
100
app/models/family/auto_merchant_detector.rb
Normal file
100
app/models/family/auto_merchant_detector.rb
Normal 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
|
15
app/models/family_merchant.rb
Normal file
15
app/models/family_merchant.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
120
app/models/provider/openai/auto_categorizer.rb
Normal file
120
app/models/provider/openai/auto_categorizer.rb
Normal 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
|
146
app/models/provider/openai/auto_merchant_detector.rb
Normal file
146
app/models/provider/openai/auto_merchant_detector.rb
Normal 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
|
|
@ -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)
|
||||
|
|
109
app/models/provider/plaid/category_alias_matcher.rb
Normal file
109
app/models/provider/plaid/category_alias_matcher.rb
Normal 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
|
461
app/models/provider/plaid/category_taxonomy.rb
Normal file
461
app/models/provider/plaid/category_taxonomy.rb
Normal 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
|
|
@ -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
|
||||
|
|
6
app/models/provider_merchant.rb
Normal file
6
app/models/provider_merchant.rb
Normal 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
90
app/models/rule.rb
Normal 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
29
app/models/rule/action.rb
Normal 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
|
43
app/models/rule/action_executor.rb
Normal file
43
app/models/rule/action_executor.rb
Normal 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
|
23
app/models/rule/action_executor/auto_categorize.rb
Normal file
23
app/models/rule/action_executor/auto_categorize.rb
Normal 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
|
23
app/models/rule/action_executor/auto_detect_merchants.rb
Normal file
23
app/models/rule/action_executor/auto_detect_merchants.rb
Normal 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
|
31
app/models/rule/action_executor/set_transaction_category.rb
Normal file
31
app/models/rule/action_executor/set_transaction_category.rb
Normal 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
|
31
app/models/rule/action_executor/set_transaction_tags.rb
Normal file
31
app/models/rule/action_executor/set_transaction_tags.rb
Normal 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
|
74
app/models/rule/condition.rb
Normal file
74
app/models/rule/condition.rb
Normal 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
|
87
app/models/rule/condition_filter.rb
Normal file
87
app/models/rule/condition_filter.rb
Normal 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
|
14
app/models/rule/condition_filter/transaction_amount.rb
Normal file
14
app/models/rule/condition_filter/transaction_amount.rb
Normal 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
|
18
app/models/rule/condition_filter/transaction_merchant.rb
Normal file
18
app/models/rule/condition_filter/transaction_merchant.rb
Normal 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
|
10
app/models/rule/condition_filter/transaction_name.rb
Normal file
10
app/models/rule/condition_filter/transaction_name.rb
Normal 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
|
46
app/models/rule/registry.rb
Normal file
46
app/models/rule/registry.rb
Normal 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
|
32
app/models/rule/registry/transaction_resource.rb
Normal file
32
app/models/rule/registry/transaction_resource.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -31,6 +31,7 @@ class TradeImport < Import
|
|||
),
|
||||
)
|
||||
end
|
||||
|
||||
Trade.import!(trades, recursive: true)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
17
app/models/transaction/ruleable.rb
Normal file
17
app/models/transaction/ruleable.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue