From a9c1e85a581dcb666d65790da219aef366807659 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:35:49 -0500 Subject: [PATCH 001/509] Bump tailwindcss-rails from 3.2.0 to 3.3.0 (#1635) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.2.0 to 3.3.0. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.2.0...v3.3.0) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2880ab28..1c95c3c2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM xpath (~> 3.2) childprocess (5.0.0) climate_control (1.2.0) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) crack (1.0.0) bigdecimal @@ -193,7 +193,7 @@ GEM rails (>= 7.0.7.2) stimulus-rails (>= 1.2) turbo-rails (>= 1.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) activesupport (>= 4.0.2) @@ -266,18 +266,18 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.1) + nokogiri (1.18.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.1-aarch64-linux-gnu) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-gnu) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) @@ -297,7 +297,7 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.2) + psych (5.2.3) date stringio public_suffix (6.0.1) @@ -355,7 +355,7 @@ GEM ffi (~> 1.0) rbs (3.8.1) logger - rdoc (6.10.0) + rdoc (6.11.0) psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.10.0) @@ -432,7 +432,7 @@ GEM railties (>= 6.0.0) stringio (3.1.2) stripe (13.3.0) - tailwindcss-rails (3.2.0) + tailwindcss-rails (3.3.0) railties (>= 7.0.0) tailwindcss-ruby tailwindcss-ruby (3.4.17) From 42d2197ea18a534e6e3c3a64c747e4c0af1d7b25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:35:58 -0500 Subject: [PATCH 002/509] Bump intercom-rails from 1.0.5 to 1.0.6 (#1636) Bumps [intercom-rails](https://github.com/intercom/intercom-rails) from 1.0.5 to 1.0.6. - [Release notes](https://github.com/intercom/intercom-rails/releases) - [Commits](https://github.com/intercom/intercom-rails/commits) --- updated-dependencies: - dependency-name: intercom-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1c95c3c2..318fbab7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -215,7 +215,7 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - intercom-rails (1.0.5) + intercom-rails (1.0.6) activesupport (> 4.0) jwt (~> 2.0) io-console (0.8.0) From 51e8fae26d6dbc3415ee8fffd0c719b9df22764b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:36:07 -0500 Subject: [PATCH 003/509] Bump stackprof from 0.2.26 to 0.2.27 (#1637) Bumps [stackprof](https://github.com/tmm1/stackprof) from 0.2.26 to 0.2.27. - [Changelog](https://github.com/tmm1/stackprof/blob/master/CHANGELOG.md) - [Commits](https://github.com/tmm1/stackprof/compare/v0.2.26...v0.2.27) --- updated-dependencies: - dependency-name: stackprof dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 318fbab7..56a5bf8b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -427,7 +427,7 @@ GEM simplecov_json_formatter (0.1.4) smart_properties (1.17.0) sorbet-runtime (0.5.11751) - stackprof (0.2.26) + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) From 39139ce21a86b1441c972cc607f644b7bd61f80a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:36:18 -0500 Subject: [PATCH 004/509] Bump aws-sdk-s3 from 1.177.0 to 1.178.0 (#1638) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.177.0 to 1.178.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 56a5bf8b..99ff4c09 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,20 +83,20 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1031.0) - aws-sdk-core (3.214.1) + aws-partitions (1.1040.0) + aws-sdk-core (3.216.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (1.97.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.178.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) From 9fadc6ba63f685e2fa6b0d1f48d857cd8d3246a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:45:00 -0500 Subject: [PATCH 005/509] Bump stripe from 13.3.0 to 13.3.1 (#1639) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.3.0 to 13.3.1. - [Release notes](https://github.com/stripe/stripe-ruby/releases) - [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-ruby/compare/v13.3.0...v13.3.1) --- updated-dependencies: - dependency-name: stripe dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 99ff4c09..8382cae8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -431,7 +431,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) - stripe (13.3.0) + stripe (13.3.1) tailwindcss-rails (3.3.0) railties (>= 7.0.0) tailwindcss-ruby From 9808641110b9a9c54365c984d5ade9d3e6d5a493 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:45:09 -0500 Subject: [PATCH 006/509] Bump ruby-lsp-rails from 0.3.29 to 0.3.30 (#1640) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.29 to 0.3.30. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.29...v0.3.30) --- updated-dependencies: - dependency-name: ruby-lsp-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8382cae8..ccc5176a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -390,12 +390,12 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.23.5) + ruby-lsp (0.23.6) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.29) + ruby-lsp-rails (0.3.30) ruby-lsp (>= 0.23.0, < 0.24.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) @@ -426,7 +426,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11751) + sorbet-runtime (0.5.11766) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) From abccba3947f6ad52d9f4fbc8cc6ca6eff5fd57ef Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 20 Jan 2025 11:37:01 -0500 Subject: [PATCH 007/509] Fix account deletion cascade bug (#1644) * Fix account deletion cascade bug * Rubocop fixes --- app/controllers/accounts_controller.rb | 4 +- app/models/account/transaction.rb | 4 +- app/views/accounts/_account.html.erb | 43 +++++++++++++++------- app/views/plaid_items/_plaid_item.html.erb | 32 ++++++++++------ test/models/account_test.rb | 6 +++ test/models/transfer_test.rb | 6 +++ 6 files changed, 65 insertions(+), 30 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4adcf710..3c0d25e7 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,8 +4,8 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync] def index - @manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically - @plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered + @manual_accounts = Current.family.accounts.manual.alphabetically + @plaid_items = Current.family.plaid_items.ordered end def summary diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index 91fc0420..6e0c576f 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -6,8 +6,8 @@ class Account::Transaction < ApplicationRecord has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings - has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :restrict_with_exception - has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception + has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy + has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy accepts_nested_attributes_for :taggings, allow_destroy: true diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index c0bb2904..8fcee360 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -8,18 +8,31 @@
- <%= link_to account.name, account, class: [(account.is_active ? "text-gray-900" : "text-gray-400"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> - <% if account.has_issues? %> -
- <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> - <%= link_to t(".troubleshoot"), issue_path(account.issues.first), class: "underline", data: { turbo_frame: :drawer } %> -
+ <% if account.scheduled_for_deletion? %> +

+ + <%= account.name %> + + + (deletion in progress...) + +

+ <% else %> + <%= link_to account.name, account, class: [(account.is_active ? "text-gray-900" : "text-gray-400"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> + <% if account.has_issues? %> +
+ <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> + <%= tag.span t(".has_issues") %> + <%= link_to t(".troubleshoot"), issue_path(account.issues.first), class: "underline", data: { turbo_frame: :drawer } %> +
+ <% end %> <% end %>
- <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> - <%= lucide_icon "pencil-line", class: "w-4 h-4 text-gray-500" %> + <% unless account.scheduled_for_deletion? %> + <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> + <%= lucide_icon "pencil-line", class: "w-4 h-4 text-gray-500" %> + <% end %> <% end %>
@@ -27,13 +40,15 @@ <%= format_money account.balance_money %>

- <%= form_with model: account, + <% unless account.scheduled_for_deletion? %> + <%= form_with model: account, namespace: account.id, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> -
- <%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %> - <%= form.label :is_active, " ".html_safe, class: "maybe-switch" %> -
+
+ <%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %> + <%= form.label :is_active, " ".html_safe, class: "maybe-switch" %> +
+ <% end %> <% end %>
diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 01330318..fc1f99e0 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -17,7 +17,12 @@
- <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> +
+ <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> + <% if plaid_item.scheduled_for_deletion? %> +

(deletion in progress...)

+ <% end %> +
<% if plaid_item.syncing? %>
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> @@ -37,7 +42,7 @@
- <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> + <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> <% end %> @@ -46,6 +51,7 @@ <%= button_to plaid_item_path(plaid_item), method: :delete, class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", + disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?, data: { turbo_confirm: { title: t(".confirm_title"), @@ -62,15 +68,17 @@
-
- <% if plaid_item.accounts.any? %> - <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> - <% else %> -
-

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

-

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

-
- <% end %> -
+ <% unless plaid_item.scheduled_for_deletion? %> +
+ <% if plaid_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> + <% else %> +
+

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

+

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

+
+ <% end %> +
+ <% end %> <% end %> diff --git a/test/models/account_test.rb b/test/models/account_test.rb index f122c17e..97c3523d 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -8,6 +8,12 @@ class AccountTest < ActiveSupport::TestCase @family = families(:dylan_family) end + test "can destroy" do + assert_difference "Account.count", -1 do + @account.destroy + end + end + test "groups accounts by type" do result = @family.accounts.by_group(period: Period.all) assets = result[:assets] diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb index ea08c758..7ca32c98 100644 --- a/test/models/transfer_test.rb +++ b/test/models/transfer_test.rb @@ -8,6 +8,12 @@ class TransferTest < ActiveSupport::TestCase @inflow = account_transactions(:transfer_in) end + test "transfer destroyed if either transaction is destroyed" do + assert_difference [ "Transfer.count", "Account::Transaction.count", "Account::Entry.count" ], -1 do + @outflow.entry.destroy + end + end + test "auto matches transfers" do outflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: 500) inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) From 72fd17770734e731ca0acc0e87db198fce5f020e Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 20 Jan 2025 15:12:53 -0500 Subject: [PATCH 008/509] Do not raise on Plaid item not found exceptions for item deletions (#1646) --- app/models/plaid_item.rb | 2 ++ test/models/plaid_item_test.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 79087900..cbbe14bb 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -131,5 +131,7 @@ class PlaidItem < ApplicationRecord def remove_plaid_item plaid_provider.remove_item(access_token) + rescue StandardError => e + Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}") end end diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index d689e855..e18b1785 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -18,4 +18,16 @@ class PlaidItemTest < ActiveSupport::TestCase @plaid_item.destroy end end + + test "if plaid item not found, silently continues with deletion" do + @plaid_provider = mock + + PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) + + @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found")) + + assert_difference "PlaidItem.count", -1 do + @plaid_item.destroy + end + end end From 67d81f866f81b90f9ef90b1dbd691183450a4056 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 20 Jan 2025 16:17:40 -0500 Subject: [PATCH 009/509] Align cascade delete behavior for transfers (#1647) * Align cascade delete behavior for transfers * Lint fix --- ...250120210449_align_transfer_cascade_behavior.rb | 14 ++++++++++++++ db/schema.rb | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20250120210449_align_transfer_cascade_behavior.rb diff --git a/db/migrate/20250120210449_align_transfer_cascade_behavior.rb b/db/migrate/20250120210449_align_transfer_cascade_behavior.rb new file mode 100644 index 00000000..06b404d3 --- /dev/null +++ b/db/migrate/20250120210449_align_transfer_cascade_behavior.rb @@ -0,0 +1,14 @@ +class AlignTransferCascadeBehavior < ActiveRecord::Migration[7.2] + def change + remove_foreign_key :transfers, :account_transactions, column: :inflow_transaction_id + remove_foreign_key :transfers, :account_transactions, column: :outflow_transaction_id + + add_foreign_key :transfers, :account_transactions, + column: :inflow_transaction_id, + on_delete: :cascade + + add_foreign_key :transfers, :account_transactions, + column: :outflow_transaction_id, + on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index dffd8081..d072986e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_01_10_012347) do +ActiveRecord::Schema[7.2].define(version: 2025_01_20_210449) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -696,7 +696,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_10_012347) do add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" - add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id" - add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id" + add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id", on_delete: :cascade + add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id", on_delete: :cascade add_foreign_key "users", "families" end From 68c570eed8810fd59b5b33cca51bbad5eabb4cb4 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 21 Jan 2025 12:42:51 -0500 Subject: [PATCH 010/509] Make tags scrollable --- app/assets/stylesheets/application.tailwind.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index de0b8978..e049f766 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -73,7 +73,7 @@ } select[multiple="multiple"] { - @apply py-2 pr-2 space-y-0.5; + @apply py-2 pr-2 space-y-0.5 overflow-y-auto; } select[multiple="multiple"] option { From 44961f36281d5b35f758d8dbb7a1fc6e309be81c Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 23 Jan 2025 10:02:48 -0600 Subject: [PATCH 011/509] Only pass in a country code on securities searches if the user location is set to "US" --- app/controllers/securities_controller.rb | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 4a3c65c4..5be3cbd9 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -5,14 +5,7 @@ class SecuritiesController < ApplicationController @securities = Security.search({ search: query, - country: country_code_filter + country: params[:country_code] == "US" ? "US" : nil }) end - - private - def country_code_filter - filter = params[:country_code] - filter = "#{filter},US" unless filter == "US" - filter - end end From e4a374772ab2d3341c7ce73d29a1edb902eeafbb Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 23 Jan 2025 10:11:09 -0600 Subject: [PATCH 012/509] Increased expiration time on storage to prevent broken images as well as implement a fix for R2/S3 conflicts. --- config/environments/development.rb | 3 +++ config/environments/production.rb | 3 +++ config/storage.yml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/config/environments/development.rb b/config/environments/development.rb index 2f4f659c..f8ad2c13 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -36,6 +36,9 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym + # Set Active Storage URL expiration time to 7 days + config.active_storage.urls_expire_in = 7.days + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.delivery_method = :letter_opener diff --git a/config/environments/production.rb b/config/environments/production.rb index ea7cd99d..8a5e81cf 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -33,6 +33,9 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym + # Set Active Storage URL expiration time to 7 days + config.active_storage.urls_expire_in = 7.days + # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil # config.action_cable.url = "wss://example.com/cable" diff --git a/config/storage.yml b/config/storage.yml index 5b6fdc3f..6c92b2ba 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -20,6 +20,8 @@ cloudflare: secret_access_key: <%= ENV['CLOUDFLARE_SECRET_ACCESS_KEY'] %> region: auto bucket: <%= ENV['CLOUDFLARE_BUCKET'] %> + request_checksum_calculation: "when_required" + response_checksum_validation: "when_required" # Removed in #702. Uncomment, add gems, update .env.example to enable. #google: From 0476f25952f568492c856f620d3352794295233a Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 23 Jan 2025 10:22:53 -0600 Subject: [PATCH 013/509] Rollback AWS SDK version to address checksum conflicts --- Gemfile | 2 +- Gemfile.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 10ad4ba6..933985df 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem "sentry-ruby" gem "sentry-rails" # Active Storage -gem "aws-sdk-s3", require: false +gem "aws-sdk-s3", "~> 1.177.0", require: false gem "image_processing", ">= 1.2" # Other diff --git a/Gemfile.lock b/Gemfile.lock index ccc5176a..a5f5947d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,8 +83,8 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1040.0) - aws-sdk-core (3.216.0) + aws-partitions (1.1042.0) + aws-sdk-core (3.216.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -92,8 +92,8 @@ GEM aws-sdk-kms (1.97.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.178.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.177.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.11.0) @@ -481,7 +481,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - aws-sdk-s3 + aws-sdk-s3 (~> 1.177.0) bcrypt (~> 3.1) bootsnap brakeman From 61321f6b1665bac054de7c1452159ab2f82c9a91 Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 24 Jan 2025 02:47:51 +0100 Subject: [PATCH 014/509] fix: Only admins can generate invite codes (#1611) * fix: Only admins can generate invite codes * fix: raise error if user is not an admin when creating invite codesss --- app/controllers/invite_codes_controller.rb | 1 + .../hostings/_invite_code_settings.html.erb | 2 +- .../invite_codes_controller_test.rb | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 test/controllers/invite_codes_controller_test.rb diff --git a/app/controllers/invite_codes_controller.rb b/app/controllers/invite_codes_controller.rb index f636a65a..fa8aa97c 100644 --- a/app/controllers/invite_codes_controller.rb +++ b/app/controllers/invite_codes_controller.rb @@ -6,6 +6,7 @@ class InviteCodesController < ApplicationController end def create + raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin? InviteCode.generate! redirect_back_or_to invite_codes_path, notice: "Code generated" end diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index 49828365..e9889d75 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -7,7 +7,7 @@ <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
- <%= form.check_box :require_invite_for_signup, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> + <%= form.check_box :require_invite_for_signup, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %> <%= form.label :require_invite_for_signup, " ".html_safe, class: "maybe-switch" %>
<% end %> diff --git a/test/controllers/invite_codes_controller_test.rb b/test/controllers/invite_codes_controller_test.rb new file mode 100644 index 00000000..ea39395f --- /dev/null +++ b/test/controllers/invite_codes_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class InviteCodesControllerTest < ActionDispatch::IntegrationTest + setup do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + end + test "admin can generate invite codes" do + sign_in users(:family_admin) + + assert_difference("InviteCode.count") do + post invite_codes_url, params: {} + end + end + + test "non-admin cannot generate invite codes" do + sign_in users(:family_member) + + assert_raises(StandardError) { post invite_codes_url, params: {} } + end +end From 43dd16e3fb06495b2a01358cd68acf24a3c1f9a1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 23 Jan 2025 21:14:01 -0500 Subject: [PATCH 015/509] Only update account balance if changed (#1676) * Only update balance if changed * Update test assertions --- app/models/account.rb | 4 +++- test/interfaces/accountable_resource_interface_test.rb | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index 19d883ca..b23a8e15 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -135,9 +135,11 @@ class Account < ApplicationRecord end def update_with_sync!(attributes) + should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance + transaction do update!(attributes) - update_balance!(attributes[:balance]) if attributes[:balance] + update_balance!(attributes[:balance]) if should_update_balance end sync_later diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index d44baf7e..913854a3 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -61,11 +61,11 @@ module AccountableResourceInterfaceTest assert_equal "#{@account.accountable_name.humanize} account created", flash[:notice] end - test "updates account balance by creating new valuation" do + test "updates account balance by creating new valuation if balance has changed" do assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do patch account_url(@account), params: { account: { - balance: 10000 + balance: 12000 } } end @@ -81,7 +81,7 @@ module AccountableResourceInterfaceTest assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do patch account_url(@account), params: { account: { - balance: 10000 + balance: 12000 } } end From 7d04ea10710ca9b6bf82b58bae4ab6d03a183713 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 24 Jan 2025 09:18:52 -0500 Subject: [PATCH 016/509] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 28ba17e6..aa6f2864 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,11 +2,18 @@ name: Bug report about: Create a report to help us improve title: 'Bug: ' -labels: ":bug: Bug" +labels: '' assignees: '' --- +**Where did this bug occur? (required)** + +- [ ] I am a self-hosted user reporting a bug from my self hosted app +- [ ] I am a user of Maybe's paid app + +_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_ + **Describe the bug** A clear and concise description of what the bug is. @@ -20,14 +27,5 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. -**What version of Maybe are you using?** -This could be "Hosted" (i.e. app.maybefinance.com) or "Self-hosted". If "Self-hosted", please include the version you're currently on. - -**What operating system and browser are you using?** -The more info the better. - **Screenshots / Recordings** If applicable, add screenshots or short video recordings to help show the bug in more detail. - -**Additional context** -Add any other context about the problem here. From 3140835f286366ec8f0c9aea430239f2c234b9db Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 24 Jan 2025 13:39:08 -0500 Subject: [PATCH 017/509] Adjust queues to prioritize account syncs (#1682) --- app/jobs/auto_upgrade_job.rb | 2 +- app/jobs/destroy_job.rb | 2 +- app/jobs/enrich_data_job.rb | 2 +- app/jobs/fetch_security_info_job.rb | 2 +- app/jobs/import_job.rb | 2 +- app/jobs/sync_job.rb | 2 +- app/jobs/user_purge_job.rb | 2 +- app/models/time_series.rb | 8 ++++++++ app/views/accounts/chart.html.erb | 8 ++++++-- app/views/pages/dashboard/_net_worth_chart.html.erb | 8 ++++++-- config/database.yml | 3 ++- config/initializers/good_job.rb | 8 ++++++++ 12 files changed, 37 insertions(+), 12 deletions(-) diff --git a/app/jobs/auto_upgrade_job.rb b/app/jobs/auto_upgrade_job.rb index 30c10bc7..34a0f3dc 100644 --- a/app/jobs/auto_upgrade_job.rb +++ b/app/jobs/auto_upgrade_job.rb @@ -1,5 +1,5 @@ class AutoUpgradeJob < ApplicationJob - queue_as :default + queue_as :latency_low def perform(*args) raise_if_disabled diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb index 2296c45f..8ea120f6 100644 --- a/app/jobs/destroy_job.rb +++ b/app/jobs/destroy_job.rb @@ -1,5 +1,5 @@ class DestroyJob < ApplicationJob - queue_as :default + queue_as :latency_low def perform(model) model.destroy diff --git a/app/jobs/enrich_data_job.rb b/app/jobs/enrich_data_job.rb index 97286b82..f20875c8 100644 --- a/app/jobs/enrich_data_job.rb +++ b/app/jobs/enrich_data_job.rb @@ -1,5 +1,5 @@ class EnrichDataJob < ApplicationJob - queue_as :default + queue_as :latency_high def perform(account) account.enrich_data diff --git a/app/jobs/fetch_security_info_job.rb b/app/jobs/fetch_security_info_job.rb index 7dff6d0f..5eaafa43 100644 --- a/app/jobs/fetch_security_info_job.rb +++ b/app/jobs/fetch_security_info_job.rb @@ -1,5 +1,5 @@ class FetchSecurityInfoJob < ApplicationJob - queue_as :default + queue_as :latency_low def perform(security_id) return unless Security.security_info_provider.present? diff --git a/app/jobs/import_job.rb b/app/jobs/import_job.rb index f7fc2c01..8a7c490e 100644 --- a/app/jobs/import_job.rb +++ b/app/jobs/import_job.rb @@ -1,5 +1,5 @@ class ImportJob < ApplicationJob - queue_as :default + queue_as :latency_medium def perform(import) import.publish diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index c6f06253..187d18f7 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -1,5 +1,5 @@ class SyncJob < ApplicationJob - queue_as :default + queue_as :latency_medium def perform(sync) sync.perform diff --git a/app/jobs/user_purge_job.rb b/app/jobs/user_purge_job.rb index ff997807..2f173f7a 100644 --- a/app/jobs/user_purge_job.rb +++ b/app/jobs/user_purge_job.rb @@ -1,5 +1,5 @@ class UserPurgeJob < ApplicationJob - queue_as :default + queue_as :latency_low def perform(user) user.purge diff --git a/app/models/time_series.rb b/app/models/time_series.rb index 09091e8f..f9ef3ffb 100644 --- a/app/models/time_series.rb +++ b/app/models/time_series.rb @@ -37,6 +37,14 @@ class TimeSeries series: self end + def empty? + values.empty? + end + + def has_current_day_value? + values.any? { |v| v.date == Date.current } + end + # `as_json` returns the data shape used by D3 charts def as_json { diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 67eee6fb..329edba2 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -17,15 +17,19 @@
- <% if series %> + <% if series.has_current_day_value? %>
+ <% elsif series.empty? %> +
+

No data available for the selected period.

+
<% else %>
-

No data available for the selected period.

+

Calculating latest balance data...

<% end %>
diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index f6bc9b5e..56d1adf7 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -1,12 +1,16 @@ <%# locals: (series:) %> -<% if series %> +<% if series.has_current_day_value? %>
+<% elsif series.empty? %> +
+

No data available for the selected period.

+
<% else %>
-

No data available for the selected period.

+

Calculating latest balance data...

<% end %> diff --git a/config/database.yml b/config/database.yml index 221e19e8..6e0652c4 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,7 +1,8 @@ default: &default adapter: postgresql encoding: unicode - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + # 3 connections for Puma, 8 for GoodJob (in async mode, the default for self-hosters) = 11 connections + pool: <%= ENV.fetch("DB_POOL_SIZE") { 11 } %> host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> port: <%= ENV.fetch("DB_PORT") { "5432" } %> user: <%= ENV.fetch("POSTGRES_USER") { nil } %> diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 9ca92c0f..7649e5af 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -11,6 +11,14 @@ Rails.application.configure do } end + # 5 queue threads + 3 for job listener, cron, executor = 8 threads allocated + config.queues = { + "latency_low" => { max_threads: 1, priority: 10 }, # ~30s jobs + "latency_low,latency_medium" => { max_threads: 2, priority: 5 }, # ~1-2 min jobs + "latency_low,latency_medium,latency_high" => { max_threads: 1, priority: 1 }, # ~5+ min jobs + "*" => { max_threads: 1, priority: 0 } # fallback queue + } + # Auth for jobs admin dashboard ActiveSupport.on_load(:good_job_application_controller) do before_action do From 7e0ec4bd8fad6642115b4f413cca0ed19754dfb9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 24 Jan 2025 13:52:40 -0500 Subject: [PATCH 018/509] Report good job connection errors to Sentry --- config/initializers/good_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 7649e5af..c6e3d33e 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -11,6 +11,8 @@ Rails.application.configure do } end + config.good_job.on_thread_error = ->(exception) { Rails.error.report(exception) } + # 5 queue threads + 3 for job listener, cron, executor = 8 threads allocated config.queues = { "latency_low" => { max_threads: 1, priority: 10 }, # ~30s jobs From e617d791d332fbb3bd344b24a3c15f90598354e5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 24 Jan 2025 20:19:13 -0500 Subject: [PATCH 019/509] Show budget averages in family currency Fixes #1689 --- app/models/category.rb | 12 ++++++++++++ .../budget_categories/_budget_category.html.erb | 2 +- .../budget_categories/_budget_category_form.html.erb | 2 +- app/views/budget_categories/show.html.erb | 10 +++++----- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 90d2ce92..ebeadbf9 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -107,6 +107,18 @@ class Category < ApplicationRecord family.category_stats.month_total_for(self, date: date) end + def avg_monthly_total_money + Money.new(avg_monthly_total, family.currency) + end + + def median_monthly_total_money + Money.new(median_monthly_total, family.currency) + end + + def month_total_money(date: Date.current) + Money.new(month_total(date: date), family.currency) + end + private def category_level_limit if subcategory? && parent.subcategory? diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index e6c0e4cb..27dc80d1 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -32,7 +32,7 @@ <% end %> <% else %>

- <%= format_money(budget_category.category.avg_monthly_total) %> avg + <%= format_money(budget_category.category.avg_monthly_total_money, precision: 0) %> avg

<% end %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index efd98dc9..83e85f8d 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -8,7 +8,7 @@

<%= budget_category.category.name %>

-

<%= format_money(Money.new(budget_category.category.avg_monthly_total, budget_category.currency), precision: 0) %>/m average

+

<%= format_money(budget_category.category.avg_monthly_total_money, precision: 0) %>/m average

diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index e9d4b759..57e6df5e 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -10,10 +10,10 @@ <% if @budget_category.budget.initialized? %>

- <%= format_money(@budget_category.actual_spending) %> + <%= format_money(@budget_category.actual_spending_money) %> / - <%= format_money(@budget_category.budgeted_spending) %> + <%= format_money(@budget_category.budgeted_spending_money) %>

<% end %>
@@ -72,7 +72,7 @@
Budgeted
- <%= format_money @budget_category.budgeted_spending %> + <%= format_money @budget_category.budgeted_spending_money %>
<% end %> @@ -80,14 +80,14 @@
Monthly average spending
- <%= format_money @budget_category.category.avg_monthly_total %> + <%= format_money @budget_category.category.avg_monthly_total_money, precision: 0 %>
Monthly median spending
- <%= format_money @budget_category.category.median_monthly_total %> + <%= format_money @budget_category.category.median_monthly_total_money, precision: 0 %>
From 217a96c02daabdc2cbc145c5501d06e89b94834f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:21:29 -0500 Subject: [PATCH 020/509] Bump sentry-rails from 5.22.1 to 5.22.2 (#1711) Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.1 to 5.22.2. - [Release notes](https://github.com/getsentry/sentry-ruby/releases) - [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-ruby/compare/5.22.1...5.22.2) --- updated-dependencies: - dependency-name: sentry-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a5f5947d..05f24c84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -219,7 +219,8 @@ GEM activesupport (> 4.0) jwt (~> 2.0) io-console (0.8.0) - irb (1.14.3) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) @@ -291,6 +292,9 @@ GEM plaid (34.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) prism (1.3.0) propshaft (1.1.0) actionpack (>= 7.0.0) @@ -413,10 +417,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.22.1) + sentry-rails (5.22.2) railties (>= 5.0) - sentry-ruby (~> 5.22.1) - sentry-ruby (5.22.1) + sentry-ruby (~> 5.22.2) + sentry-ruby (5.22.2) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) simplecov (0.22.0) From beb6e36577df998d0072b170e8f4f70cc8fde1d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:21:38 -0500 Subject: [PATCH 021/509] Bump selenium-webdriver from 4.27.0 to 4.28.0 (#1710) Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.27.0 to 4.28.0. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES) - [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.27.0...selenium-4.28.0) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 05f24c84..66c8ec40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -365,7 +365,7 @@ GEM regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) - rexml (3.3.9) + rexml (3.4.0) rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -406,12 +406,12 @@ GEM ffi (~> 1.12) logger ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) - selenium-webdriver (4.27.0) + selenium-webdriver (4.28.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From f9d4270a75f857e1ecd966497ba3f42133e51092 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:21:47 -0500 Subject: [PATCH 022/509] Bump good_job from 4.7.0 to 4.8.2 (#1709) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.7.0 to 4.8.2. - [Release notes](https://github.com/bensheldon/good_job/releases) - [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md) - [Commits](https://github.com/bensheldon/good_job/compare/v4.7.0...v4.8.2) --- updated-dependencies: - dependency-name: good_job dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 66c8ec40..e6eccb9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -176,7 +176,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.7.0) + good_job (4.8.2) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From 91149ceff8fd05e6eae707a814e0b86eb4c1ff6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:22:23 -0500 Subject: [PATCH 023/509] Bump tailwindcss-rails from 3.3.0 to 3.3.1 (#1708) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.3.0 to 3.3.1. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.3.0...v3.3.1) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e6eccb9f..e264871a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -436,9 +436,9 @@ GEM railties (>= 6.0.0) stringio (3.1.2) stripe (13.3.1) - tailwindcss-rails (3.3.0) + tailwindcss-rails (3.3.1) railties (>= 7.0.0) - tailwindcss-ruby + tailwindcss-ruby (~> 3.0) tailwindcss-ruby (3.4.17) tailwindcss-ruby (3.4.17-aarch64-linux) tailwindcss-ruby (3.4.17-arm-linux) From caf359deed69236dd53f723aed73a3e258c73251 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:22:39 -0500 Subject: [PATCH 024/509] Bump ruby-lsp-rails from 0.3.30 to 0.3.31 (#1703) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.30 to 0.3.31. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.30...v0.3.31) --- updated-dependencies: - dependency-name: ruby-lsp-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e264871a..b4d7b9e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -399,7 +399,7 @@ GEM prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.30) + ruby-lsp-rails (0.3.31) ruby-lsp (>= 0.23.0, < 0.24.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) @@ -430,7 +430,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11766) + sorbet-runtime (0.5.11781) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) From 8be5bb07c870dab7f15f47d775222e077f5aef93 Mon Sep 17 00:00:00 2001 From: Julien Bertazzo Lambert <42924425+JLambertazzo@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:29:50 -0500 Subject: [PATCH 025/509] fix: reuse correct expense total calculation in budget.rb (#1699) --- app/models/budget.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index 2abfe2cf..d59e9d41 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -113,7 +113,7 @@ class Budget < ApplicationRecord end def actual_spending - budget_categories.reject(&:subcategory?).sum(&:actual_spending) + expense_categories_with_totals.total_money.amount end def available_to_spend From 5cc592d38fa272dc6173e92ea35ba9b5634a0540 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:30:01 -0500 Subject: [PATCH 026/509] Bump plaid from 34.0.0 to 35.0.0 (#1707) Bumps [plaid](https://github.com/plaid/plaid-ruby) from 34.0.0 to 35.0.0. - [Release notes](https://github.com/plaid/plaid-ruby/releases) - [Changelog](https://github.com/plaid/plaid-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/plaid/plaid-ruby/compare/v34.0.0...v35.0.0) --- updated-dependencies: - dependency-name: plaid dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index b4d7b9e8..74c69d0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -289,7 +289,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) - plaid (34.0.0) + plaid (35.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) pp (0.6.2) From 2a1b5fab1a0c725e5973f30706ec59278a437356 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:30:19 -0500 Subject: [PATCH 027/509] Bump erb_lint from 0.8.0 to 0.9.0 (#1704) Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.8.0 to 0.9.0. - [Release notes](https://github.com/Shopify/erb-lint/releases) - [Commits](https://github.com/Shopify/erb-lint/compare/v0.8.0...v0.9.0) --- updated-dependencies: - dependency-name: erb_lint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 74c69d0e..f6b521e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,7 +143,7 @@ GEM dotenv (= 3.1.7) railties (>= 6.1) drb (2.2.1) - erb_lint (0.8.0) + erb_lint (0.9.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -227,7 +227,7 @@ GEM json (2.9.1) jwt (2.10.1) base64 - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -285,7 +285,7 @@ GEM sawyer (~> 0.9) pagy (9.3.3) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc pg (1.5.9) @@ -366,7 +366,7 @@ GEM reline (0.6.0) io-console (~> 0.5) rexml (3.4.0) - rubocop (1.70.0) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) From eabfb7aae151b5429bc878737641fd8800689527 Mon Sep 17 00:00:00 2001 From: Harshit Chaudhary <55315065+Harry-kp@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:03:56 +0530 Subject: [PATCH 028/509] Added Decimal Support in min transaction (#1681) * Added Decimal Support in min transaction * fix: Using inbuilt money field * Updated Test --- app/views/credit_cards/_form.html.erb | 5 +++-- test/controllers/credit_cards_controller_test.rb | 4 ++-- test/system/accounts_test.rb | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/credit_cards/_form.html.erb b/app/views/credit_cards/_form.html.erb index ff9e7582..2405b77b 100644 --- a/app/views/credit_cards/_form.html.erb +++ b/app/views/credit_cards/_form.html.erb @@ -13,10 +13,11 @@
- <%= credit_card_form.number_field :minimum_payment, + <%= credit_card_form.money_field :minimum_payment, label: t("credit_cards.form.minimum_payment"), placeholder: t("credit_cards.form.minimum_payment_placeholder"), - min: 0 %> + default_currency: Current.family.currency %> + <%= credit_card_form.number_field :apr, label: t("credit_cards.form.apr"), placeholder: t("credit_cards.form.apr_placeholder"), diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index 8c119e8b..6d475a73 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -21,7 +21,7 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest accountable_type: "CreditCard", accountable_attributes: { available_credit: 5000, - minimum_payment: 25, + minimum_payment: 25.51, apr: 15.99, expiration_date: 2.years.from_now.to_date, annual_fee: 99 @@ -36,7 +36,7 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_equal 1000, created_account.balance assert_equal "USD", created_account.currency assert_equal 5000, created_account.accountable.available_credit - assert_equal 25, created_account.accountable.minimum_payment + assert_equal 25.51, created_account.accountable.minimum_payment assert_equal 15.99, created_account.accountable.apr assert_equal 2.years.from_now.to_date, created_account.accountable.expiration_date assert_equal 99, created_account.accountable.annual_fee diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 9ce77814..334d185d 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -50,7 +50,7 @@ class AccountsTest < ApplicationSystemTestCase test "can create credit card account" do assert_account_created "CreditCard" do fill_in "Available credit", with: 1000 - fill_in "Minimum payment", with: 25 + fill_in "account[accountable_attributes][minimum_payment]", with: 25.51 fill_in "APR", with: 15.25 fill_in "Expiration date", with: 1.year.from_now.to_date fill_in "Annual fee", with: 100 From d2a7aef6efc6ab54e44f673fc6506fcb6b821f7e Mon Sep 17 00:00:00 2001 From: Georgi Tapalilov Date: Mon, 27 Jan 2025 16:34:13 +0200 Subject: [PATCH 029/509] fix n+1 for categories (#1693) --- app/models/budget.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index d59e9d41..9f6d6c07 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -94,7 +94,7 @@ class Budget < ApplicationRecord # Continuous gray segment for empty budgets return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid? - segments = budget_categories.map do |bc| + segments = budget_categories.includes(:category).map do |bc| { color: bc.category.color, amount: bc.actual_spending, id: bc.id } end From 2a202576f8b9f35b8c98ba57e53d93fef1b139e5 Mon Sep 17 00:00:00 2001 From: Nikhil Badyal <59223300+nikhilbadyal@users.noreply.github.com> Date: Mon, 27 Jan 2025 23:33:15 +0530 Subject: [PATCH 030/509] Added more periods (#1714) --- app/helpers/accounts_helper.rb | 14 ++++++++++++++ app/helpers/forms_helper.rb | 15 +++++++++++++-- app/models/period.rb | 13 +++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 24179b85..04be4b52 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -8,6 +8,18 @@ module AccountsHelper days_apart = (end_date - start_date).to_i + # Handle specific cases + if start_date == Date.current.beginning_of_week && end_date == Date.current + return "Current Week to Date (CWD)" + elsif start_date == Date.current.beginning_of_month && end_date == Date.current + return "Current Month to Date (MTD)" + elsif start_date == Date.current.beginning_of_quarter && end_date == Date.current + return "Current Quarter to Date (CQD)" + elsif start_date == Date.current.beginning_of_year && end_date == Date.current + return "Current Year to Date (YTD)" + end + + # Default cases case days_apart when 1 "vs. yesterday" @@ -15,6 +27,8 @@ module AccountsHelper "vs. last week" when 30, 31 "vs. last month" + when 90 + "vs. last 3 months" when 365, 366 "vs. last year" else diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 2099770c..2a187d62 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -18,9 +18,20 @@ module FormsHelper end def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0") - periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ] + periods_for_select = [ + %w[CWD current_week], # Current Week to Date + %w[7D last_7_days], + %w[MTD current_month], # Month to Date + %w[1M last_30_days], + %w[CQD current_quarter], # Quarter to Date + %w[3M last_90_days], + %w[YTD current_year], # Year to Date + %w[1Y last_365_days] + ] + form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" }) - end +end + def currencies_for_select Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] } diff --git a/app/models/period.rb b/app/models/period.rb index 4a1e8762..c1d28e99 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -25,10 +25,15 @@ class Period end BUILTIN = [ - new(name: "all", date_range: nil..Date.current), - new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current), - new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current), - new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current) + new(name: "all", date_range: nil..Date.current), + new(name: "current_week", date_range: Date.current.beginning_of_week..Date.current), + new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current), + new(name: "current_month", date_range: Date.current.beginning_of_month..Date.current), + new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current), + new(name: "current_quarter", date_range: Date.current.beginning_of_quarter..Date.current), + new(name: "last_90_days", date_range: 90.days.ago.to_date..Date.current), + new(name: "current_year", date_range: Date.current.beginning_of_year..Date.current), + new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current) ] INDEX = BUILTIN.index_by(&:name) From 7265f585186e9aee468a1836fcd2685c77932567 Mon Sep 17 00:00:00 2001 From: Eirik H Date: Mon, 27 Jan 2025 19:04:36 +0100 Subject: [PATCH 031/509] Add cabin / cottage as a property type (#1658) * Add cabin / cottage as property type Signed-off-by: Eirik H * Update app/models/property.rb Signed-off-by: Zach Gollwitzer --------- Signed-off-by: Eirik H Signed-off-by: Zach Gollwitzer Co-authored-by: Zach Gollwitzer --- app/models/property.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/property.rb b/app/models/property.rb index c173e2cb..ec1d530f 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -6,7 +6,8 @@ class Property < ApplicationRecord [ "Multi-Family Home", "multi_family_home" ], [ "Condominium", "condominium" ], [ "Townhouse", "townhouse" ], - [ "Investment Property", "investment_property" ] + [ "Investment Property", "investment_property" ], + [ "Second Home", "second_home" ] ] has_one :address, as: :addressable, dependent: :destroy From 6c8974a086d0bdb513f356d5e4fab12f83278f6a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 27 Jan 2025 13:18:02 -0500 Subject: [PATCH 032/509] Update render.yaml Signed-off-by: Zach Gollwitzer --- render.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index 69b298ff..e527a335 100644 --- a/render.yaml +++ b/render.yaml @@ -13,7 +13,10 @@ services: branch: main healthCheckPath: /up buildCommand: "./bin/render-build.sh" - preDeployCommand: "bundle exec rails db:migrate" + + # Uncomment if you are on a paid plan, and remove RUN_DB_MIGRATIONS_IN_BUILD_STEP from below + # preDeployCommand: "bundle exec rails db:migrate" + startCommand: "bundle exec rails server" envVars: - key: DATABASE_URL From 0b4e314f58fdd0477069ced308f2a39711dc005c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 27 Jan 2025 16:29:30 -0500 Subject: [PATCH 033/509] Bump bundler version, address Docker build failures --- Dockerfile | 4 +-- Gemfile.lock | 74 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0e4742d4..236d5f4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile ARG RUBY_VERSION=3.3.5 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails @@ -19,7 +19,7 @@ ENV RAILS_ENV="production" \ # Throw-away build stage to reduce size of final image -FROM base as build +FROM base AS build # Install packages needed to build gems RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config diff --git a/Gemfile.lock b/Gemfile.lock index f6b521e1..8fb554f0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,8 +83,8 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1042.0) - aws-sdk-core (3.216.1) + aws-partitions (1.1043.0) + aws-sdk-core (3.217.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -124,7 +124,8 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - childprocess (5.0.0) + childprocess (5.1.0) + logger (~> 1.5) climate_control (1.2.0) concurrent-ruby (1.3.5) connection_pool (2.5.0) @@ -137,7 +138,7 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) - docile (1.4.0) + docile (1.4.1) dotenv (3.1.7) dotenv-rails (3.1.7) dotenv (= 3.1.7) @@ -165,12 +166,14 @@ GEM net-http (>= 0.5.0) faraday-retry (2.2.1) faraday (~> 2.0) - ffi (1.17.0-aarch64-linux-gnu) - ffi (1.17.0-arm-linux-gnu) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86-linux-gnu) - ffi (1.17.0-x86_64-darwin) - ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86_64-darwin) + ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -183,8 +186,9 @@ GEM fugit (>= 1.11.0) railties (>= 6.1.0) thor (>= 1.0.0) - hashdiff (1.1.1) - highline (3.0.1) + hashdiff (1.1.2) + highline (3.1.2) + reline hotwire-livereload (2.0.0) actioncable (>= 7.0.0) listen (>= 3.0.0) @@ -228,9 +232,10 @@ GEM jwt (2.10.1) base64 language_server-protocol (3.17.0.4) - launchy (3.0.1) + launchy (3.1.0) addressable (~> 2.8) childprocess (~> 5.0) + logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) listen (3.9.0) @@ -249,15 +254,14 @@ GEM matrix (0.4.2) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.8) minitest (5.25.4) mocha (2.7.1) ruby2_keywords (>= 0.0.5) - msgpack (1.7.2) + msgpack (1.7.5) multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.1) + net-imap (0.5.5) date net-protocol net-pop (0.1.2) @@ -267,19 +271,22 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.2) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) + nokogiri (1.18.2-aarch64-linux-musl) + racc (~> 1.4) nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) + nokogiri (1.18.2-arm-linux-musl) + racc (~> 1.4) nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.18.2-x86_64-linux-musl) + racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) @@ -338,7 +345,7 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (7.0.9) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails-settings-cached (2.9.6) @@ -376,18 +383,18 @@ GEM rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) - rubocop-minitest (0.35.0) + rubocop-minitest (0.36.0) rubocop (>= 1.61, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-performance (1.21.0) + rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails-omakase (1.0.0) rubocop @@ -427,7 +434,7 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) sorbet-runtime (0.5.11781) @@ -435,11 +442,10 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) - stripe (13.3.1) + stripe (13.4.0) tailwindcss-rails (3.3.1) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) - tailwindcss-ruby (3.4.17) tailwindcss-ruby (3.4.17-aarch64-linux) tailwindcss-ruby (3.4.17-arm-linux) tailwindcss-ruby (3.4.17-arm64-darwin) @@ -469,7 +475,8 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) @@ -478,11 +485,16 @@ GEM PLATFORMS aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl arm-linux + arm-linux-gnu + arm-linux-musl arm64-darwin - x86-linux x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES aws-sdk-s3 (~> 1.177.0) @@ -540,4 +552,4 @@ RUBY VERSION ruby 3.3.5p100 BUNDLED WITH - 2.5.22 + 2.6.3 From de90b292010785a595947af756d7099564417b8d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 27 Jan 2025 16:56:46 -0500 Subject: [PATCH 034/509] Add RejectedTransfer model, simplify auto matching (#1690) * Allow transfers to match when inflow is after outflow * Simplify transfer auto matching with RejectedTransfer model * Validations * Reset migrations --- app/controllers/transfers_controller.rb | 9 ++- app/models/account.rb | 58 +++++++++++++++++++ app/models/account/entry.rb | 20 +++++-- app/models/account/syncer.rb | 2 +- app/models/account/transaction.rb | 6 +- app/models/rejected_transfer.rb | 4 ++ app/models/transfer.rb | 55 +++++------------- .../_transaction_category.html.erb | 2 +- .../transactions/_transfer_match.html.erb | 1 + .../account/transfer_matches/new.html.erb | 4 +- app/views/transfers/_transfer.html.erb | 5 +- app/views/transfers/update.turbo_stream.erb | 12 ++-- config/locales/models/transfer/en.yml | 3 +- ...0250124224316_create_rejected_transfers.rb | 44 ++++++++++++++ db/schema.rb | 14 ++++- test/controllers/transfers_controller_test.rb | 4 +- test/models/account_test.rb | 36 +++++++++++- test/models/transfer_test.rb | 21 ++++--- 18 files changed, 221 insertions(+), 79 deletions(-) create mode 100644 app/models/rejected_transfer.rb create mode 100644 db/migrate/20250124224316_create_rejected_transfers.rb diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index a7d04bad..b66054c2 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -38,11 +38,14 @@ class TransfersController < ApplicationController end def update - Transfer.transaction do - @transfer.update!(transfer_update_params.except(:category_id)) - @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) + if transfer_update_params[:status] == "rejected" + @transfer.reject! + elsif transfer_update_params[:status] == "confirmed" + @transfer.confirm! end + @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) + respond_to do |format| format.html { redirect_back_or_to transactions_url, notice: t(".success") } format.turbo_stream diff --git a/app/models/account.rb b/app/models/account.rb index b23a8e15..c11b532d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -159,4 +159,62 @@ class Account < ApplicationRecord entryable: Account::Valuation.new end end + + def transfer_match_candidates + Account::Entry.select([ + "inflow_candidates.entryable_id as inflow_transaction_id", + "outflow_candidates.entryable_id as outflow_transaction_id", + "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff" + ]).from("account_entries inflow_candidates") + .joins(" + JOIN account_entries outflow_candidates ON ( + inflow_candidates.amount < 0 AND + outflow_candidates.amount > 0 AND + inflow_candidates.amount = -outflow_candidates.amount AND + inflow_candidates.currency = outflow_candidates.currency AND + inflow_candidates.account_id <> outflow_candidates.account_id AND + inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 + ) + ").joins(" + LEFT JOIN transfers existing_transfers ON ( + existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR + existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id + ) + ") + .joins("LEFT JOIN rejected_transfers ON ( + rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND + rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id + )") + .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id") + .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id") + .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.family_id, self.family_id) + .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'") + .where(existing_transfers: { id: nil }) + .order("date_diff ASC") # Closest matches first + end + + def auto_match_transfers! + # Exclude already matched transfers + candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil }) + + # Track which transactions we've already matched to avoid duplicates + used_transaction_ids = Set.new + + candidates = [] + + Transfer.transaction do + candidates_scope.each do |match| + next if used_transaction_ids.include?(match.inflow_transaction_id) || + used_transaction_ids.include?(match.outflow_transaction_id) + + Transfer.create!( + inflow_transaction_id: match.inflow_transaction_id, + outflow_transaction_id: match.outflow_transaction_id, + ) + + used_transaction_ids << match.inflow_transaction_id + used_transaction_ids << match.outflow_transaction_id + end + end + end end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 9cbfb32d..9001c451 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -77,12 +77,20 @@ class Account::Entry < ApplicationRecord end def transfer_match_candidates - account.family.entries - .where.not(account_id: account_id) - .where.not(id: id) - .where(amount: -amount) - .where(currency: currency) - .where(date: (date - 4.days)..(date + 4.days)) + candidates_scope = account.transfer_match_candidates + + candidates_scope = if amount.negative? + candidates_scope.where("inflow_candidates.entryable_id = ?", entryable_id) + else + candidates_scope.where("outflow_candidates.entryable_id = ?", entryable_id) + end + + candidates_scope.map do |pm| + Transfer.new( + inflow_transaction_id: pm.inflow_transaction_id, + outflow_transaction_id: pm.outflow_transaction_id, + ) + end end class << self diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 7817a308..d5a5ec84 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -5,7 +5,7 @@ class Account::Syncer end def run - Transfer.auto_match_for_account(account) + account.auto_match_transfers! holdings = sync_holdings balances = sync_balances(holdings) diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index 6e0c576f..a3a91bf9 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -9,6 +9,10 @@ class Account::Transaction < ApplicationRecord has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy + # We keep track of rejected transfers to avoid auto-matching them again + has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy + has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy + accepts_nested_attributes_for :taggings, allow_destroy: true scope :active, -> { where(excluded: false) } @@ -24,6 +28,6 @@ class Account::Transaction < ApplicationRecord end def transfer? - transfer.present? && transfer.status != "rejected" + transfer.present? end end diff --git a/app/models/rejected_transfer.rb b/app/models/rejected_transfer.rb new file mode 100644 index 00000000..9d1a1ce4 --- /dev/null +++ b/app/models/rejected_transfer.rb @@ -0,0 +1,4 @@ +class RejectedTransfer < ApplicationRecord + belongs_to :inflow_transaction, class_name: "Account::Transaction" + belongs_to :outflow_transaction, class_name: "Account::Transaction" +end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index b0266389..3cb1f07b 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -2,13 +2,15 @@ class Transfer < ApplicationRecord belongs_to :inflow_transaction, class_name: "Account::Transaction" belongs_to :outflow_transaction, class_name: "Account::Transaction" - enum :status, { pending: "pending", confirmed: "confirmed", rejected: "rejected" } + enum :status, { pending: "pending", confirmed: "confirmed" } + + validates :inflow_transaction_id, uniqueness: true + validates :outflow_transaction_id, uniqueness: true validate :transfer_has_different_accounts validate :transfer_has_opposite_amounts validate :transfer_within_date_range validate :transfer_has_same_family - validate :inflow_on_or_after_outflow class << self def from_accounts(from_account:, to_account:, date:, amount:) @@ -42,45 +44,19 @@ class Transfer < ApplicationRecord status: "confirmed" ) end + end - def auto_match_for_account(account) - matches = Account::Entry.select([ - "inflow_candidates.entryable_id as inflow_transaction_id", - "outflow_candidates.entryable_id as outflow_transaction_id" - ]).from("account_entries inflow_candidates") - .joins(" - JOIN account_entries outflow_candidates ON ( - inflow_candidates.amount < 0 AND - outflow_candidates.amount > 0 AND - inflow_candidates.amount = -outflow_candidates.amount AND - inflow_candidates.currency = outflow_candidates.currency AND - inflow_candidates.account_id <> outflow_candidates.account_id AND - inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 AND - inflow_candidates.date >= outflow_candidates.date - ) - ").joins(" - LEFT JOIN transfers existing_transfers ON ( - existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR - existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id - ) - ") - .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id") - .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id") - .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", account.family_id, account.family_id) - .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'") - .where(existing_transfers: { id: nil }) - - Transfer.transaction do - matches.each do |match| - Transfer.create!( - inflow_transaction_id: match.inflow_transaction_id, - outflow_transaction_id: match.outflow_transaction_id, - ) - end - end + def reject! + Transfer.transaction do + RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id) + destroy! end end + def confirm! + update!(status: "confirmed") + end + def sync_account_later inflow_transaction.entry.sync_account_later outflow_transaction.entry.sync_account_later @@ -119,11 +95,6 @@ class Transfer < ApplicationRecord end private - def inflow_on_or_after_outflow - return unless inflow_transaction.present? && outflow_transaction.present? - errors.add(:base, :inflow_must_be_on_or_after_outflow) if inflow_transaction.entry.date < outflow_transaction.entry.date - end - def transfer_has_different_accounts return unless inflow_transaction.present? && outflow_transaction.present? errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb index 1f204028..5489d310 100644 --- a/app/views/account/transactions/_transaction_category.html.erb +++ b/app/views/account/transactions/_transaction_category.html.erb @@ -1,7 +1,7 @@ <%# locals: (entry:) %>
"> - <% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? || entry.account_transaction.transfer&.rejected? %> + <% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? %> <%= render "categories/menu", transaction: entry.account_transaction %> <% else %> <%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %> diff --git a/app/views/account/transactions/_transfer_match.html.erb b/app/views/account/transactions/_transfer_match.html.erb index 7175e02e..810611d2 100644 --- a/app/views/account/transactions/_transfer_match.html.erb +++ b/app/views/account/transactions/_transfer_match.html.erb @@ -19,6 +19,7 @@ <%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }), method: :patch, + data: { turbo: false }, class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", title: "Reject match" do %> <%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %> diff --git a/app/views/account/transfer_matches/new.html.erb b/app/views/account/transfer_matches/new.html.erb index 49a450bf..237c4d0e 100644 --- a/app/views/account/transfer_matches/new.html.erb +++ b/app/views/account/transfer_matches/new.html.erb @@ -23,7 +23,7 @@ ) %> <% else %> <%= render "account/transfer_matches/matching_fields", - form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %> + form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.outflow_transaction.entry }, accounts: @accounts %> <% end %>
@@ -50,7 +50,7 @@ ) %> <% else %> <%= render "account/transfer_matches/matching_fields", - form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %> + form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.inflow_transaction.entry }, accounts: @accounts %> <% end %>
diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb index ad0527db..05b6c564 100644 --- a/app/views/transfers/_transfer.html.erb +++ b/app/views/transfers/_transfer.html.erb @@ -24,10 +24,6 @@ is confirmed"> <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %> - <% elsif transfer.status == "rejected" %> - - Rejected - <% else %> Auto-matched @@ -42,6 +38,7 @@ <%= button_to transfer_path(transfer, transfer: { status: "rejected" }), method: :patch, + data: { turbo: false }, class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", title: "Reject match" do %> <%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %> diff --git a/app/views/transfers/update.turbo_stream.erb b/app/views/transfers/update.turbo_stream.erb index 90d5a7d5..08c08b47 100644 --- a/app/views/transfers/update.turbo_stream.erb +++ b/app/views/transfers/update.turbo_stream.erb @@ -1,17 +1,19 @@ -<%= turbo_stream.replace @transfer %> +<% unless @transfer.destroyed? %> + <%= turbo_stream.replace @transfer %> -<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.inflow_transaction.entry.id}", + <%= turbo_stream.replace "category_menu_account_entry_#{@transfer.inflow_transaction.entry.id}", partial: "account/transactions/transaction_category", locals: { entry: @transfer.inflow_transaction.entry } %> -<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.outflow_transaction.entry.id}", + <%= turbo_stream.replace "category_menu_account_entry_#{@transfer.outflow_transaction.entry.id}", partial: "account/transactions/transaction_category", locals: { entry: @transfer.outflow_transaction.entry } %> -<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.inflow_transaction.entry.id}", + <%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.inflow_transaction.entry.id}", partial: "account/transactions/transfer_match", locals: { entry: @transfer.inflow_transaction.entry } %> -<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.outflow_transaction.entry.id}", + <%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.outflow_transaction.entry.id}", partial: "account/transactions/transfer_match", locals: { entry: @transfer.outflow_transaction.entry } %> +<% end %> diff --git a/config/locales/models/transfer/en.yml b/config/locales/models/transfer/en.yml index 6aa640be..b6014ba4 100644 --- a/config/locales/models/transfer/en.yml +++ b/config/locales/models/transfer/en.yml @@ -13,7 +13,8 @@ en: amounts must_have_single_currency: Transfer must have a single currency must_be_from_same_family: Transfer must be from the same family - inflow_must_be_on_or_after_outflow: Inflow transaction must be on or after outflow transaction + inflow_cannot_be_in_multiple_transfers: Inflow transaction cannot be part of multiple transfers + outflow_cannot_be_in_multiple_transfers: Outflow transaction cannot be part of multiple transfers transfer: name: Transfer to %{to_account} payment_name: Payment to %{to_account} diff --git a/db/migrate/20250124224316_create_rejected_transfers.rb b/db/migrate/20250124224316_create_rejected_transfers.rb new file mode 100644 index 00000000..3055a42b --- /dev/null +++ b/db/migrate/20250124224316_create_rejected_transfers.rb @@ -0,0 +1,44 @@ +class CreateRejectedTransfers < ActiveRecord::Migration[7.2] + def change + create_table :rejected_transfers, id: :uuid do |t| + t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid + t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid + t.timestamps + end + + add_index :rejected_transfers, [ :inflow_transaction_id, :outflow_transaction_id ], unique: true + + reversible do |dir| + dir.up do + execute <<~SQL + INSERT INTO rejected_transfers (inflow_transaction_id, outflow_transaction_id, created_at, updated_at) + SELECT + inflow_transaction_id, + outflow_transaction_id, + created_at, + updated_at + FROM transfers + WHERE status = 'rejected' + SQL + + execute <<~SQL + DELETE FROM transfers + WHERE status = 'rejected' + SQL + end + + dir.down do + execute <<~SQL + INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at) + SELECT + inflow_transaction_id, + outflow_transaction_id, + 'rejected', + created_at, + updated_at + FROM rejected_transfers + SQL + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d072986e..5b0a0697 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_01_20_210449) do +ActiveRecord::Schema[7.2].define(version: 2025_01_24_224316) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -527,6 +527,16 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_20_210449) do t.string "area_unit" end + create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "inflow_transaction_id", null: false + t.uuid "outflow_transaction_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true + t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" + t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id" + end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker" t.string "name" @@ -691,6 +701,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_20_210449) do add_foreign_key "merchants", "families" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" + add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id" + add_foreign_key "rejected_transfers", "account_transactions", column: "outflow_transaction_id" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 391937e8..3c2961ca 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -25,8 +25,8 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest end end - test "can destroy transfer" do - assert_difference -> { Transfer.count } => -1, -> { Account::Transaction.count } => 0 do + test "soft deletes transfer" do + assert_difference -> { Transfer.count }, -1 do delete transfer_url(transfers(:one)) end end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 97c3523d..3e29a5a1 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,7 +1,7 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase - include SyncableInterfaceTest + include SyncableInterfaceTest, Account::EntriesTestHelper setup do @account = @syncable = accounts(:depository) @@ -65,4 +65,38 @@ class AccountTest < ActiveSupport::TestCase assert_equal 0, @account.series(currency: "NZD").values.count end end + + test "auto-matches transfers" do + outflow_entry = create_transaction(date: 1.day.ago.to_date, account: @account, amount: 500) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) + + assert_difference -> { Transfer.count } => 1 do + @account.auto_match_transfers! + end + end + + # In this scenario, our matching logic should find 4 potential matches. These matches should be ranked based on + # days apart, then de-duplicated so that we aren't auto-matching the same transaction across multiple transfers. + test "when 2 options exist, only auto-match one at a time, ranked by days apart" do + yesterday_outflow = create_transaction(date: 1.day.ago.to_date, account: @account, amount: 500) + yesterday_inflow = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500) + + today_outflow = create_transaction(date: Date.current, account: @account, amount: 500) + today_inflow = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) + + assert_difference -> { Transfer.count } => 2 do + @account.auto_match_transfers! + end + end + + test "does not auto-match any transfers that have been rejected by user already" do + outflow = create_transaction(date: Date.current, account: @account, amount: 500) + inflow = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) + + RejectedTransfer.create!(inflow_transaction_id: inflow.entryable_id, outflow_transaction_id: outflow.entryable_id) + + assert_no_difference -> { Transfer.count } do + @account.auto_match_transfers! + end + end end diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb index 7ca32c98..a3a4ef42 100644 --- a/test/models/transfer_test.rb +++ b/test/models/transfer_test.rb @@ -14,15 +14,6 @@ class TransferTest < ActiveSupport::TestCase end end - test "auto matches transfers" do - outflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: 500) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) - - assert_difference -> { Transfer.count } => 1 do - Transfer.auto_match_for_account(accounts(:depository)) - end - end - test "transfer has different accounts, opposing amounts, and within 4 days of each other" do outflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: 500) inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) @@ -131,4 +122,16 @@ class TransferTest < ActiveSupport::TestCase transfer.save! end end + + test "transaction can only belong to one transfer" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry1 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) + inflow_entry2 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) + + Transfer.create!(inflow_transaction: inflow_entry1.account_transaction, outflow_transaction: outflow_entry.account_transaction) + + assert_raises ActiveRecord::RecordInvalid do + Transfer.create!(inflow_transaction: inflow_entry2.account_transaction, outflow_transaction: outflow_entry.account_transaction) + end + end end From 8256d116ddd3b892d2a4256abdad86d1da25fd95 Mon Sep 17 00:00:00 2001 From: Jestin Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Tue, 28 Jan 2025 06:28:45 +0530 Subject: [PATCH 035/509] fix: category update sync (#1720) --- app/views/categories/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index d0cda832..8927c0bf 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:, categories:) %>
- <%= styled_form_with model: category, class: "space-y-4" do |f| %> + <%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %> From d428a1f9546ae1ffb35eaa297c173804229384fb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 27 Jan 2025 19:59:16 -0500 Subject: [PATCH 036/509] Bump to Ruby 3.4.1 (#1721) --- .devcontainer/Dockerfile | 2 +- .ruby-version | 2 +- Dockerfile | 2 +- Gemfile.lock | 10 ++++++---- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 21d4c4b9..949a9b9d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG RUBY_VERSION=3.3.5 +ARG RUBY_VERSION=3.4.1 FROM ruby:${RUBY_VERSION}-slim-bullseye RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ diff --git a/.ruby-version b/.ruby-version index fa7adc7a..47b322c9 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.5 +3.4.1 diff --git a/Dockerfile b/Dockerfile index 236d5f4d..2cad94b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.3.5 +ARG RUBY_VERSION=3.4.1 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile.lock b/Gemfile.lock index 8fb554f0..61a15574 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -451,8 +451,8 @@ GEM tailwindcss-ruby (3.4.17-arm64-darwin) tailwindcss-ruby (3.4.17-x86_64-darwin) tailwindcss-ruby (3.4.17-x86_64-linux) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) timeout (0.4.3) turbo-rails (2.0.11) @@ -460,7 +460,9 @@ GEM railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) uri (1.0.2) useragent (0.16.11) vcr (6.3.1) @@ -549,7 +551,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.3.5p100 + ruby 3.4.1p0 BUNDLED WITH 2.6.3 From 247d91b99de256465001590b9b77c91df1942f9c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 28 Jan 2025 12:03:43 -0500 Subject: [PATCH 037/509] Lazy load synth logos (#1731) --- app/views/account/holdings/_holding.html.erb | 2 +- app/views/account/holdings/show.html.erb | 2 +- app/views/account/transactions/_transaction.html.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/account/holdings/_holding.html.erb b/app/views/account/holdings/_holding.html.erb index c70e6515..e33f4f54 100644 --- a/app/views/account/holdings/_holding.html.erb +++ b/app/views/account/holdings/_holding.html.erb @@ -3,7 +3,7 @@ <%= turbo_frame_tag dom_id(holding) do %>
- <%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %> + <%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full", loading: "lazy" %>
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb index 92475cf1..390964b7 100644 --- a/app/views/account/holdings/show.html.erb +++ b/app/views/account/holdings/show.html.erb @@ -6,7 +6,7 @@ <%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
- <%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", class: "w-9 h-9 rounded-full" %> + <%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", loading: "lazy", class: "w-9 h-9 rounded-full" %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 03b01e1d..ce6cd187 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -13,7 +13,7 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> <% if transaction.merchant&.icon_url %> - <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> + <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> <% else %> <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> <% end %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index fc1f99e0..895af1cf 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -8,7 +8,7 @@
<% if plaid_item.logo.attached? %> - <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %> + <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> <% else %>
<%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> From 3b0f8ae8c2f336e9685ef75e25cb7816c63f9465 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 28 Jan 2025 14:08:04 -0500 Subject: [PATCH 038/509] Only build armv7 on official releases (#1732) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ea13cc6..e55e802c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -65,7 +65,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false From 0b17976256ee8eb9a4ccd15d5aabd024674e859c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elvis=20Serr=C3=A3o?= Date: Thu, 30 Jan 2025 14:35:30 -0300 Subject: [PATCH 039/509] Don't allow a subcategory to be assigned to another subcategory to ensure 1 level of nesting max (#1730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve category level limit validation * Set categories list only for non parents * Disable select field * Add info about the disabled select * Don’t render a select input for parent categories * Handle correctly turbo_stream request format * Add turbo_stream format to requests on create and update action's tests * Remove no_content status from update action * Revert "Remove no_content status from update action" This reverts commit 866140c196337d0f38ecf42fc3808fee3d502708. * Revert "Add turbo_stream format to requests on create and update action's tests" This reverts commit c6bf21490f79c657acbdda58c4cece7ba25d999a. * Add correct redirect url for both html and turbo_stream formats * Remove useless turbo_frame_tag --- app/controllers/categories_controller.rb | 26 +++++++++++++++++++----- app/models/category.rb | 7 ++++++- app/views/categories/_form.html.erb | 6 ++++-- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 03752869..a9d3d105 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -2,6 +2,7 @@ class CategoriesController < ApplicationController layout :with_sidebar before_action :set_category, only: %i[edit update destroy] + before_action :set_categories, only: %i[update edit] before_action :set_transaction, only: :create def index @@ -10,7 +11,7 @@ class CategoriesController < ApplicationController def new @category = Current.family.categories.new color: Category::COLORS.sample - @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) + set_categories end def create @@ -27,19 +28,26 @@ class CategoriesController < ApplicationController format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } end else - @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) + set_categories render :new, status: :unprocessable_entity end end def edit - @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) end def update - @category.update! category_params + if @category.update(category_params) + flash[:notice] = t(".success") - redirect_back_or_to categories_path, notice: t(".success") + redirect_target_url = request.referer || categories_path + respond_to do |format| + format.html { redirect_back_or_to categories_path, notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } + end + else + render :edit, status: :unprocessable_entity + end end def destroy @@ -59,6 +67,14 @@ class CategoriesController < ApplicationController @category = Current.family.categories.find(params[:id]) end + def set_categories + @categories = unless @category.parent? + Current.family.categories.alphabetically.roots.where.not(id: @category.id) + else + [] + end + end + def set_transaction if params[:transaction_id].present? @transaction = Current.family.transactions.find(params[:transaction_id]) diff --git a/app/models/category.rb b/app/models/category.rb index ebeadbf9..d0ccaa60 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -15,6 +15,7 @@ class Category < ApplicationRecord validate :nested_category_matches_parent_classification scope :alphabetically, -> { order(:name) } + scope :roots, -> { where(parent_id: nil) } scope :incomes, -> { where(classification: "income") } scope :expenses, -> { where(classification: "expense") } @@ -91,6 +92,10 @@ class Category < ApplicationRecord end end + def parent? + subcategories.any? + end + def subcategory? parent.present? end @@ -121,7 +126,7 @@ class Category < ApplicationRecord private def category_level_limit - if subcategory? && parent.subcategory? + if (subcategory? && parent.subcategory?) || (parent? && subcategory?) errors.add(:parent, "can't have more than 2 levels of subcategories") end end diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 8927c0bf..884d5509 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:, categories:) %>
- <%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %> + <%= styled_form_with model: category, class: "space-y-4" do |f| %>
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %> @@ -34,7 +34,9 @@
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> - <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %> + <% unless category.parent? %> + <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent? %> + <% end %>
From 282c05345d59aa60e69aaaf82aa416cf25a11ebb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 30 Jan 2025 14:12:01 -0500 Subject: [PATCH 040/509] Preserve transaction filters and transaction focus across page visits (#1733) * Preserve transaction filters across page visits * Preserve params when per_page is updated * Autofocus selected transactions * Lint fixes * Fix syntax error * Fix filter clearing * Update e2e tests for new UI * Consolidate focus behavior into concern * Lint fixes --- app/controllers/account/entries_controller.rb | 26 ----- .../concerns/accountable_resource.rb | 8 ++ app/controllers/concerns/scroll_focusable.rb | 21 +++++ app/controllers/transactions_controller.rb | 78 ++++++++++++++- app/helpers/application_helper.rb | 20 ---- app/helpers/transactions_helper.rb | 17 ---- .../controllers/focus_record_controller.js | 21 +++++ .../controllers/selectable_link_controller.js | 20 ++++ app/views/account/entries/index.html.erb | 93 ------------------ .../transactions/_transaction.html.erb | 4 +- app/views/accounts/show/_activity.html.erb | 94 ++++++++++++++++++- app/views/application/_pagination.html.erb | 42 ++++----- app/views/credit_cards/show.html.erb | 2 +- app/views/investments/show.html.erb | 2 +- app/views/loans/show.html.erb | 2 +- app/views/properties/show.html.erb | 2 +- app/views/transactions/index.html.erb | 2 +- .../transactions/searches/_form.html.erb | 2 + .../transactions/searches/_menu.html.erb | 2 +- .../searches/filters/_badge.html.erb | 2 +- app/views/vehicles/show.html.erb | 2 +- config/locales/models/transfer/en.yml | 9 +- config/locales/views/account/entries/en.yml | 11 --- config/locales/views/accounts/en.yml | 11 +++ config/locales/views/application/en.yml | 3 - config/locales/views/categories/en.yml | 4 +- config/locales/views/layout/en.yml | 2 +- config/routes.rb | 8 +- ...03_store_transaction_filters_in_session.rb | 5 + db/schema.rb | 3 +- test/application_system_test_case.rb | 2 +- .../account/entries_controller_test.rb | 13 --- test/system/trades_test.rb | 2 +- test/system/transactions_test.rb | 18 ++-- 34 files changed, 310 insertions(+), 243 deletions(-) delete mode 100644 app/controllers/account/entries_controller.rb create mode 100644 app/controllers/concerns/scroll_focusable.rb create mode 100644 app/javascript/controllers/focus_record_controller.js create mode 100644 app/javascript/controllers/selectable_link_controller.js delete mode 100644 app/views/account/entries/index.html.erb create mode 100644 db/migrate/20250128203303_store_transaction_filters_in_session.rb delete mode 100644 test/controllers/account/entries_controller_test.rb diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb deleted file mode 100644 index b36cdbc6..00000000 --- a/app/controllers/account/entries_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -class Account::EntriesController < ApplicationController - layout :with_sidebar - - before_action :set_account - - def index - @q = search_params - @pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10") - end - - private - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - - def entries_scope - scope = Current.family.entries - scope = scope.where(account: @account) if @account - scope - end - - def search_params - params.fetch(:q, {}) - .permit(:search) - end -end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 8f6a3244..1cdf7baa 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -2,6 +2,8 @@ module AccountableResource extend ActiveSupport::Concern included do + include ScrollFocusable + layout :with_sidebar before_action :set_account, only: [ :show, :edit, :update, :destroy ] before_action :set_link_token, only: :new @@ -22,6 +24,12 @@ module AccountableResource end def show + @q = params.fetch(:q, {}).permit(:search) + entries = @account.entries.search(@q).reverse_chronological + + set_focused_record(entries, params[:focused_record_id]) + + @pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) }) end def edit diff --git a/app/controllers/concerns/scroll_focusable.rb b/app/controllers/concerns/scroll_focusable.rb new file mode 100644 index 00000000..7eb47a1b --- /dev/null +++ b/app/controllers/concerns/scroll_focusable.rb @@ -0,0 +1,21 @@ +module ScrollFocusable + extend ActiveSupport::Concern + + def set_focused_record(record_scope, record_id, default_per_page: 10) + return unless record_id.present? + + @focused_record = record_scope.find_by(id: record_id) + + record_index = record_scope.pluck(:id).index(record_id) + + return unless record_index + + page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1 + + if params[:page]&.to_i != page_of_focused_record + ( + redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id)) + ) + end + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 80248ef2..7478894b 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,10 +1,21 @@ class TransactionsController < ApplicationController + include ScrollFocusable + layout :with_sidebar + before_action :store_params!, only: :index + def index @q = search_params search_query = Current.family.transactions.search(@q).reverse_chronological - @pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") + + set_focused_record(search_query, params[:focused_record_id], default_per_page: 50) + + @pagy, @transaction_entries = pagy( + search_query, + limit: params[:per_page].presence || default_params[:per_page], + params: ->(params) { params.except(:focused_record_id) } + ) totals_query = search_query.incomes_and_expenses family_currency = Current.family.currency @@ -18,13 +29,76 @@ class TransactionsController < ApplicationController } end + def clear_filter + updated_params = stored_params.deep_dup + + q_params = updated_params["q"] || {} + + param_key = params[:param_key] + param_value = params[:param_value] + + if q_params[param_key].is_a?(Array) + q_params[param_key].delete(param_value) + q_params.delete(param_key) if q_params[param_key].empty? + else + q_params.delete(param_key) + end + + updated_params["q"] = q_params.presence + Current.session.update!(prev_transaction_page_params: updated_params) + + redirect_to transactions_path(updated_params) + end + private def search_params - params.fetch(:q, {}) + cleaned_params = params.fetch(:q, {}) .permit( :start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [] ) + .to_h + .compact_blank + + cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present? + + cleaned_params + end + + def store_params! + if should_restore_params? + params_to_restore = {} + + params_to_restore[:q] = stored_params["q"].presence || default_params[:q] + params_to_restore[:page] = stored_params["page"].presence || default_params[:page] + params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page] + + redirect_to transactions_path(params_to_restore) + else + Current.session.update!( + prev_transaction_page_params: { + q: search_params, + page: params[:page], + per_page: params[:per_page] + } + ) + end + end + + def should_restore_params? + request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?) + end + + def stored_params + Current.session.prev_transaction_page_params + end + + def default_params + { + q: {}, + page: 1, + per_page: 50 + } end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4f1c9499..5d561bc1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -176,24 +176,4 @@ module ApplicationHelper cookies[:admin] == "true" end - - def custom_pagy_url_for(pagy, page, current_path: nil) - if current_path.blank? - pagy_url_for(pagy, page) - else - uri = URI.parse(current_path) - params = URI.decode_www_form(uri.query || "").to_h - - # Delete existing page param if it exists - params.delete("page") - # Add new page param unless it's page 1 - params["page"] = page unless page == 1 - - if params.empty? - uri.path - else - "#{uri.path}?#{URI.encode_www_form(params)}" - end - end - end end diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index 5c6f4d7b..dd729c47 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -18,21 +18,4 @@ module TransactionsHelper def get_default_transaction_search_filter transaction_search_filters[0] end - - def transactions_path_without_param(param_key, param_value) - updated_params = request.query_parameters.deep_dup - - q_params = updated_params[:q] || {} - - current_value = q_params[param_key] - if current_value.is_a?(Array) - q_params[param_key] = current_value - [ param_value ] - else - q_params.delete(param_key) - end - - updated_params[:q] = q_params - - transactions_path(updated_params) - end end diff --git a/app/javascript/controllers/focus_record_controller.js b/app/javascript/controllers/focus_record_controller.js new file mode 100644 index 00000000..0cc3fc9a --- /dev/null +++ b/app/javascript/controllers/focus_record_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="focus-record" +export default class extends Controller { + static values = { + id: String, + }; + + connect() { + const element = document.getElementById(this.idValue); + + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + + // Remove the focused_record_id parameter from URL + const url = new URL(window.location); + url.searchParams.delete("focused_record_id"); + window.history.replaceState({}, "", url); + } + } +} diff --git a/app/javascript/controllers/selectable_link_controller.js b/app/javascript/controllers/selectable_link_controller.js new file mode 100644 index 00000000..7d93a7ce --- /dev/null +++ b/app/javascript/controllers/selectable_link_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="selectable-link" +export default class extends Controller { + connect() { + this.element.addEventListener("change", this.handleChange.bind(this)); + } + + disconnect() { + this.element.removeEventListener("change", this.handleChange.bind(this)); + } + + handleChange(event) { + const paramName = this.element.name; + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.set(paramName, event.target.value); + + Turbo.visit(currentUrl.toString()); + } +} diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb deleted file mode 100644 index 1ac6de61..00000000 --- a/app/views/account/entries/index.html.erb +++ /dev/null @@ -1,93 +0,0 @@ -<%= turbo_frame_tag dom_id(@account, "entries") do %> -
-
- <%= tag.h2 t(".title"), class: "font-medium text-lg" %> - <% unless @account.plaid_account_id.present? %> -
- - -
- <% end %> -
- -
- <%= form_with url: account_entries_path, - id: "entries-search", - scope: :q, - method: :get, - data: { controller: "auto-submit-form" } do |form| %> -
-
-
- <%= lucide_icon("search", class: "w-5 h-5 text-gray-500") %> - <%= hidden_field_tag :account_id, @account.id %> - <%= form.search_field :search, - placeholder: "Search entries by name", - value: @q[:search], - class: "form-field__input placeholder:text-sm placeholder:text-gray-500", - "data-auto-submit-form-target": "auto" %> -
-
-
- <% end %> -
- - <% if @entries.empty? %> -

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

- <% else %> - <%= tag.div id: dom_id(@account, "entries_bulk_select"), - data: { - controller: "bulk-select", - bulk_select_singular_label_value: t(".entry"), - bulk_select_plural_label_value: t(".entries") - } do %> - - -
-
- <%= check_box_tag "selection_entry", - class: "maybe-checkbox maybe-checkbox--light", - data: { action: "bulk-select#togglePageSelection" } %> -

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

-
- <%= tag.p t(".amount"), class: "col-span-2 justify-self-end" %> - <%= tag.p t(".balance"), class: "col-span-2 justify-self-end" %> -
- -
-
-
- <% calculator = Account::BalanceTrendCalculator.for(@entries) %> - <%= entries_by_date(@entries) do |entries| %> - <% entries.each do |entry| %> - <%= render entry, balance_trend: calculator&.trend_for(entry) %> - <% end %> - <% end %> -
-
- -
- <%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page], tab: params[:tab]) %> -
-
- <% end %> - <% end %> -
-<% end %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index ce6cd187..fcfdb29c 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,7 +1,7 @@ <%# locals: (entry:, selectable: true, balance_trend: nil) %> <% transaction, account = entry.account_transaction, entry.account %> -
+
">
"> <% if selectable %> <%= check_box_tag dom_id(entry, "selection"), @@ -45,7 +45,7 @@ <% if entry.account_transaction.transfer? %> <%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %> <% else %> - <%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %> + <%= link_to entry.account.name, account_path(entry.account, tab: "transactions", focused_record_id: entry.id), data: { turbo_frame: "_top" }, class: "hover:underline" %> <% end %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 86dc7038..0b0657ed 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -1,5 +1,95 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page], tab: params[:tab]) do %> - <%= render "account/entries/loading" %> +<%= turbo_frame_tag dom_id(account, "entries") do %> +
+
+ <%= tag.h2 t(".title"), class: "font-medium text-lg" %> + <% unless @account.plaid_account_id.present? %> +
+ + +
+ <% end %> +
+ +
+ <%= form_with url: account_path(account), + id: "entries-search", + scope: :q, + method: :get, + data: { controller: "auto-submit-form" } do |form| %> +
+
+
+ <%= lucide_icon("search", class: "w-5 h-5 text-gray-500") %> + <%= hidden_field_tag :account_id, @account.id %> + <%= form.search_field :search, + placeholder: "Search entries by name", + value: @q[:search], + class: "form-field__input placeholder:text-sm placeholder:text-gray-500", + "data-auto-submit-form-target": "auto" %> +
+
+
+ <% end %> +
+ + <% if @entries.empty? %> +

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

+ <% else %> + <%= tag.div id: dom_id(@account, "entries_bulk_select"), + data: { + controller: "bulk-select", + bulk_select_singular_label_value: t(".entry"), + bulk_select_plural_label_value: t(".entries") + } do %> + + +
+
+ <%= check_box_tag "selection_entry", + class: "maybe-checkbox maybe-checkbox--light", + data: { action: "bulk-select#togglePageSelection" } %> +

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

+
+ <%= tag.p t(".amount"), class: "col-span-2 justify-self-end" %> + <%= tag.p t(".balance"), class: "col-span-2 justify-self-end" %> +
+ +
+
+
+ <% calculator = Account::BalanceTrendCalculator.for(@entries) %> + <%= entries_by_date(@entries) do |entries| %> + <% entries.each do |entry| %> + <%= render entry, balance_trend: calculator&.trend_for(entry) %> + <% end %> + <% end %> +
+
+ +
+ <%= render "pagination", pagy: @pagy %> +
+
+ <% end %> + <% end %> +
<% end %> diff --git a/app/views/application/_pagination.html.erb b/app/views/application/_pagination.html.erb index 13da2cd9..10b27327 100644 --- a/app/views/application/_pagination.html.erb +++ b/app/views/application/_pagination.html.erb @@ -1,11 +1,12 @@ -<%# locals: (pagy:, current_path: nil) %> +<%# locals: (pagy:) %> + diff --git a/app/views/credit_cards/show.html.erb b/app/views/credit_cards/show.html.erb index 2135dee9..4ba5252b 100644 --- a/app/views/credit_cards/show.html.erb +++ b/app/views/credit_cards/show.html.erb @@ -1,6 +1,6 @@ <%= render "accounts/show/template", account: @account, tabs: render("accounts/show/tabs", account: @account, tabs: [ + { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "overview", contents: render("credit_cards/overview", account: @account) }, - { key: "activity", contents: render("accounts/show/activity", account: @account) } ]) %> diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index bc271e68..2336282e 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -16,8 +16,8 @@
<%= render "accounts/show/tabs", account: @account, tabs: [ - { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, { key: "activity", contents: render("accounts/show/activity", account: @account) }, + { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, ] %>
<% end %> diff --git a/app/views/loans/show.html.erb b/app/views/loans/show.html.erb index 64e9403c..55bffa52 100644 --- a/app/views/loans/show.html.erb +++ b/app/views/loans/show.html.erb @@ -1,6 +1,6 @@ <%= render "accounts/show/template", account: @account, tabs: render("accounts/show/tabs", account: @account, tabs: [ + { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "overview", contents: render("loans/overview", account: @account) }, - { key: "activity", contents: render("accounts/show/activity", account: @account) } ]) %> diff --git a/app/views/properties/show.html.erb b/app/views/properties/show.html.erb index 24d42147..e6f8c34f 100644 --- a/app/views/properties/show.html.erb +++ b/app/views/properties/show.html.erb @@ -2,6 +2,6 @@ account: @account, header: render("accounts/show/header", account: @account, subtitle: @account.property.address), tabs: render("accounts/show/tabs", account: @account, tabs: [ + { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "overview", contents: render("properties/overview", account: @account) }, - { key: "activity", contents: render("accounts/show/activity", account: @account) } ]) %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 37d82f29..ac5b9494 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -1,4 +1,4 @@ -
+
<%= render "header" %> <%= render "summary", totals: @totals %> diff --git a/app/views/transactions/searches/_form.html.erb b/app/views/transactions/searches/_form.html.erb index 84f628b3..2e6ad04c 100644 --- a/app/views/transactions/searches/_form.html.erb +++ b/app/views/transactions/searches/_form.html.erb @@ -3,6 +3,8 @@ scope: :q, method: :get, data: { controller: "auto-submit-form" } do |form| %> + <%= hidden_field_tag :per_page, params[:per_page] %> +
diff --git a/app/views/transactions/searches/_menu.html.erb b/app/views/transactions/searches/_menu.html.erb index a42840ef..e63029da 100644 --- a/app/views/transactions/searches/_menu.html.erb +++ b/app/views/transactions/searches/_menu.html.erb @@ -33,7 +33,7 @@
<% if @q.present? %> - <%= link_to t(".clear_filters"), transactions_path, class: "btn btn--ghost" %> + <%= link_to t(".clear_filters"), transactions_path(clear_filters: true), class: "btn btn--ghost" %> <% end %>
diff --git a/app/views/transactions/searches/filters/_badge.html.erb b/app/views/transactions/searches/filters/_badge.html.erb index f0ec8b1a..1fd2da28 100644 --- a/app/views/transactions/searches/filters/_badge.html.erb +++ b/app/views/transactions/searches/filters/_badge.html.erb @@ -41,7 +41,7 @@
<% end %> - <%= link_to transactions_path_without_param(param_key, param_value), data: { id: "clear-param-btn", turbo: false }, class: "flex items-center" do %> + <%= button_to clear_filter_transactions_path(param_key: param_key, param_value: param_value), method: :delete, data: { turbo: false }, class: "flex items-center" do %> <%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %> <% end %> diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb index 097a9bab..555cd926 100644 --- a/app/views/vehicles/show.html.erb +++ b/app/views/vehicles/show.html.erb @@ -1,6 +1,6 @@ <%= render "accounts/show/template", account: @account, tabs: render("accounts/show/tabs", account: @account, tabs: [ + { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "overview", contents: render("vehicles/overview", account: @account) }, - { key: "activity", contents: render("accounts/show/activity", account: @account) } ]) %> diff --git a/config/locales/models/transfer/en.yml b/config/locales/models/transfer/en.yml index b6014ba4..094194f2 100644 --- a/config/locales/models/transfer/en.yml +++ b/config/locales/models/transfer/en.yml @@ -6,16 +6,17 @@ en: transfer: attributes: base: + inflow_cannot_be_in_multiple_transfers: Inflow transaction cannot be + part of multiple transfers must_be_from_different_accounts: Transfer must have different accounts + must_be_from_same_family: Transfer must be from the same family must_be_within_date_range: Transfer transaction dates must be within 4 days of each other must_have_opposite_amounts: Transfer transactions must have opposite amounts must_have_single_currency: Transfer must have a single currency - must_be_from_same_family: Transfer must be from the same family - inflow_cannot_be_in_multiple_transfers: Inflow transaction cannot be part of multiple transfers - outflow_cannot_be_in_multiple_transfers: Outflow transaction cannot be part of multiple transfers + outflow_cannot_be_in_multiple_transfers: Outflow transaction cannot + be part of multiple transfers transfer: name: Transfer to %{to_account} payment_name: Payment to %{to_account} - diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index 395ac91f..10b2acdb 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -9,17 +9,6 @@ en: empty: description: Try adding an entry, editing filters or refining your search title: No entries found - index: - amount: Amount - balance: Balance - date: Date - entries: entries - entry: entry - new: New - new_balance: New balance - new_transaction: New transaction - no_entries: No entries found - title: Activity loading: loading: Loading entries... update: diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 3180b37e..94aa32b2 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -34,6 +34,17 @@ en: title: How would you like to add it? title: What would you like to add? show: + activity: + amount: Amount + balance: Balance + date: Date + entries: entries + entry: entry + new: New + new_balance: New balance + new_transaction: New transaction + no_entries: No entries found + title: Activity chart: balance: Balance owed: Amount owed diff --git a/config/locales/views/application/en.yml b/config/locales/views/application/en.yml index dfa964f6..2ecb7a04 100644 --- a/config/locales/views/application/en.yml +++ b/config/locales/views/application/en.yml @@ -1,8 +1,5 @@ --- en: - application: - pagination: - rows_per_page: Rows per page number: currency: format: diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 9ff612e9..1b3ee826 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -15,10 +15,10 @@ en: form: placeholder: Category name index: - categories: Categories bootstrap: Use default categories - categories_incomes: Income categories + categories: Categories categories_expenses: Expense categories + categories_incomes: Income categories empty: No categories found new: New category menu: diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 9054ee29..80443b74 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -15,8 +15,8 @@ en: description: Issue Description sidebar: accounts: Accounts + budgeting: Budgeting dashboard: Dashboard new_account: New account portfolio: Portfolio transactions: Transactions - budgeting: Budgeting diff --git a/config/routes.rb b/config/routes.rb index d49ca187..cec4c1fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -82,8 +82,6 @@ Rails.application.routes.draw do namespace :account do resources :holdings, only: %i[index new show destroy] - resources :entries, only: :index - resources :transactions, only: %i[show new create update destroy] do resource :transfer_match, only: %i[new create] resource :category, only: :update, controller: :transaction_categories @@ -109,7 +107,11 @@ Rails.application.routes.draw do end end - resources :transactions, only: :index + resources :transactions, only: :index do + collection do + delete :clear_filter + end + end # Convenience routes for polymorphic paths # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123 diff --git a/db/migrate/20250128203303_store_transaction_filters_in_session.rb b/db/migrate/20250128203303_store_transaction_filters_in_session.rb new file mode 100644 index 00000000..00c7ee5c --- /dev/null +++ b/db/migrate/20250128203303_store_transaction_filters_in_session.rb @@ -0,0 +1,5 @@ +class StoreTransactionFiltersInSession < ActiveRecord::Migration[7.2] + def change + add_column :sessions, :prev_transaction_page_params, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 5b0a0697..54a65580 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_01_24_224316) do +ActiveRecord::Schema[7.2].define(version: 2025_01_28_203303) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -569,6 +569,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_24_224316) do t.datetime "updated_at", null: false t.uuid "active_impersonator_session_id" t.datetime "subscribed_at" + t.jsonb "prev_transaction_page_params", default: {} t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["user_id"], name: "index_sessions_on_user_id" end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 83b45e4f..9e369a8c 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -5,7 +5,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase Capybara.default_max_wait_time = 5 end - driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ] + driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ] private diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb deleted file mode 100644 index c0ce72c4..00000000 --- a/test/controllers/account/entries_controller_test.rb +++ /dev/null @@ -1,13 +0,0 @@ -require "test_helper" - -class Account::EntriesControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in @user = users(:family_admin) - @entry = account_entries(:transaction) - end - - test "gets index" do - get account_entries_path(account_id: @entry.account.id) - assert_response :success - end -end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 9ca85c08..ab0c6109 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -79,7 +79,7 @@ class TradesTest < ApplicationSystemTestCase end def visit_account_portfolio - visit account_path(@account) + visit account_path(@account, tab: "holdings") end def select_combobox_option(text) diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 76ab1363..3fc0b5e1 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -6,7 +6,7 @@ class TransactionsTest < ApplicationSystemTestCase Account::Entry.delete_all # clean slate - @uncategorized_transaction = create_transaction("one", 12.days.ago.to_date, 100) + create_transaction("one", 12.days.ago.to_date, 100) create_transaction("two", 10.days.ago.to_date, 100) create_transaction("three", 9.days.ago.to_date, 100) create_transaction("four", 8.days.ago.to_date, 100) @@ -15,7 +15,7 @@ class TransactionsTest < ApplicationSystemTestCase create_transaction("seven", 4.days.ago.to_date, 100) create_transaction("eight", 3.days.ago.to_date, 100) create_transaction("nine", 1.days.ago.to_date, 100) - create_transaction("ten", 1.days.ago.to_date, 100) + @uncategorized_transaction = create_transaction("ten", 1.days.ago.to_date, 100) create_transaction("eleven", Date.current, 100, category: categories(:food_and_drink), tags: [ tags(:one) ], merchant: merchants(:amazon)) @transactions = @user.family.entries @@ -124,13 +124,13 @@ class TransactionsTest < ApplicationSystemTestCase assert_text "No entries found" within "ul#transaction-search-filters" do - find("li", text: account.name).first("a").click - find("li", text: "on or after #{10.days.ago.to_date}").first("a").click - find("li", text: "on or before #{1.day.ago.to_date}").first("a").click - find("li", text: "Income").first("a").click - find("li", text: "less than 200").first("a").click - find("li", text: category.name).first("a").click - find("li", text: merchant.name).first("a").click + find("li", text: account.name).first("button").click + find("li", text: "on or after #{10.days.ago.to_date}").first("button").click + find("li", text: "on or before #{1.day.ago.to_date}").first("button").click + find("li", text: "Income").first("button").click + find("li", text: "less than 200").first("button").click + find("li", text: category.name).first("button").click + find("li", text: merchant.name).first("button").click end assert_selector "#" + dom_id(@transaction), count: 1 From 0696e1f2f73aa07a1a1adfb185fc8caf83698b19 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 30 Jan 2025 13:13:37 -0600 Subject: [PATCH 041/509] Add/remove members and invitations (#1744) * Add/remove members and invitations * Lint --- app/controllers/invitations_controller.rb | 18 +++++ .../settings/profiles_controller.rb | 24 +++++++ app/views/settings/profiles/show.html.erb | 68 +++++++++++++------ config/initializers/sentry.rb | 2 +- config/locales/views/invitations/en.yml | 4 ++ config/locales/views/settings/en.yml | 13 ++++ config/routes.rb | 4 +- .../invitations_controller_test.rb | 33 +++++++-- .../settings/profiles_controller_test.rb | 41 ++++++++++- test/fixtures/invitations.yml | 10 +++ 10 files changed, 188 insertions(+), 29 deletions(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 020b4707..ca37435a 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -34,6 +34,24 @@ class InvitationsController < ApplicationController end end + def destroy + unless Current.user.admin? + flash[:alert] = t("invitations.destroy.not_authorized") + redirect_to settings_profile_path + return + end + + @invitation = Current.family.invitations.find(params[:id]) + + if @invitation.destroy + flash[:notice] = t("invitations.destroy.success") + else + flash[:alert] = t("invitations.destroy.failure") + end + + redirect_to settings_profile_path + end + private def invitation_params diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 882824ec..443ba16b 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -4,4 +4,28 @@ class Settings::ProfilesController < SettingsController @users = Current.family.users.order(:created_at) @pending_invitations = Current.family.invitations.pending end + + def destroy + unless Current.user.admin? + flash[:alert] = t("settings.profiles.destroy.not_authorized") + redirect_to settings_profile_path + return + end + + @user = Current.family.users.find(params[:user_id]) + + if @user == Current.user + flash[:alert] = t("settings.profiles.destroy.cannot_remove_self") + redirect_to settings_profile_path + return + end + + if @user.destroy + flash[:notice] = t("settings.profiles.destroy.member_removed") + else + flash[:alert] = t("settings.profiles.destroy.member_removal_failed") + end + + redirect_to settings_profile_path + end end diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c21775d6..5f0d5e10 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -43,6 +43,21 @@

<%= user.role %>

+ <% if Current.user.admin? && user != Current.user %> +
+ <%= button_to settings_profile_path(user_id: user), + method: :delete, + class: "text-red-500 hover:text-red-700", + data: { turbo_confirm: { + title: t(".confirm_remove_member.title"), + body: t(".confirm_remove_member.body", name: user.display_name), + accept: t(".remove_member"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} do %> + <%= lucide_icon "x", class: "w-5 h-5" %> + <% end %> +
+ <% end %>
<% end %> <% if @pending_invitations.any? %> @@ -59,25 +74,40 @@
- <% if self_hosted? %> -
-

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

- - - -
- <% end %> +
+ <% if self_hosted? %> +
+

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

+ + + +
+ <% end %> + <% if Current.user.admin? %> + <%= button_to invitation_path(invitation), + method: :delete, + class: "text-red-500 hover:text-red-700", + data: { turbo_confirm: { + title: t(".confirm_remove_invitation.title"), + body: t(".confirm_remove_invitation.body", email: invitation.email), + accept: t(".remove_invitation"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} do %> + <%= lucide_icon "x", class: "w-5 h-5" %> + <% end %> + <% end %> +
<% end %> <% end %> diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index e4ce7417..ad1a5da7 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -3,7 +3,7 @@ if ENV["SENTRY_DSN"].present? config.dsn = ENV["SENTRY_DSN"] config.environment = ENV["RAILS_ENV"] config.breadcrumbs_logger = [ :active_support_logger, :http_logger ] - config.enabled_environments = %w[development production] + config.enabled_environments = %w[production] # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 5ce17ce5..db862831 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -4,6 +4,10 @@ en: create: failure: Could not send invitation success: Invitation sent successfully + destroy: + not_authorized: You are not authorized to manage invitations. + success: Invitation was successfully removed. + failure: There was a problem removing the invitation. new: email_label: Email Address email_placeholder: Enter email address diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index f784db97..5bfa97d4 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -48,11 +48,24 @@ en: theme_title: Theme timezone: Timezone profiles: + destroy: + not_authorized: You are not authorized to remove members. + cannot_remove_self: You cannot remove yourself from the account. + member_removed: Member was successfully removed. + member_removal_failed: There was a problem removing the member. show: confirm_delete: body: Are you sure you want to permanently delete your account? This action is irreversible. title: Delete account? + confirm_remove_member: + title: Remove Member + body: Are you sure you want to remove %{name} from your account? + remove_member: Remove Member + confirm_remove_invitation: + title: Remove Invitation + body: Are you sure you want to remove the invitation for %{email}? + remove_invitation: Remove Invitation danger_zone_title: Danger Zone delete_account: Delete account delete_account_warning: Deleting your account will permanently remove all diff --git a/config/routes.rb b/config/routes.rb index cec4c1fd..d83ac11b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,7 +20,7 @@ Rails.application.routes.draw do end namespace :settings do - resource :profile, only: :show + resource :profile, only: [ :show, :destroy ] resource :preferences, only: :show resource :hosting, only: %i[show update] resource :billing, only: :show @@ -142,7 +142,7 @@ Rails.application.routes.draw do resources :exchange_rate_provider_missings, only: :update end - resources :invitations, only: [ :new, :create ] do + resources :invitations, only: [ :new, :create, :destroy ] do get :accept, on: :member end diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index b6b7edbe..e6a596e1 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -2,7 +2,7 @@ require "test_helper" class InvitationsControllerTest < ActionDispatch::IntegrationTest setup do - sign_in @user = users(:family_admin) + sign_in @admin = users(:family_admin) @invitation = invitations(:one) end @@ -25,7 +25,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest invitation = Invitation.order(created_at: :desc).first assert_equal "member", invitation.role - assert_equal @user, invitation.inviter + assert_equal @admin, invitation.inviter assert_equal "new@example.com", invitation.email assert_redirected_to settings_profile_path assert_equal I18n.t("invitations.create.success"), flash[:notice] @@ -59,8 +59,8 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest invitation = Invitation.order(created_at: :desc).first assert_equal "admin", invitation.role - assert_equal @user.family, invitation.family - assert_equal @user, invitation.inviter + assert_equal @admin.family, invitation.family + assert_equal @admin, invitation.inviter end test "should handle invalid invitation creation" do @@ -86,4 +86,29 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest get accept_invitation_url("invalid-token") assert_response :not_found end + + test "admin can remove pending invitation" do + assert_difference("Invitation.count", -1) do + delete invitation_url(@invitation) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.destroy.success"), flash[:notice] + end + + test "non-admin cannot remove invitations" do + sign_in users(:family_member) + + assert_no_difference("Invitation.count") do + delete invitation_url(@invitation) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.destroy.not_authorized"), flash[:alert] + end + + test "should handle invalid invitation removal" do + delete invitation_url(id: "invalid-id") + assert_response :not_found + end end diff --git a/test/controllers/settings/profiles_controller_test.rb b/test/controllers/settings/profiles_controller_test.rb index ff6b36ed..27d62c17 100644 --- a/test/controllers/settings/profiles_controller_test.rb +++ b/test/controllers/settings/profiles_controller_test.rb @@ -2,11 +2,46 @@ require "test_helper" class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest setup do - sign_in @user = users(:family_admin) + @admin = users(:family_admin) + @member = users(:family_member) end - test "get" do - get settings_profile_url + test "should get show" do + sign_in @admin + get settings_profile_path assert_response :success end + + test "admin can remove a family member" do + sign_in @admin + assert_difference("User.count", -1) do + delete settings_profile_path(user_id: @member) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.member_removed"), flash[:notice] + assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) } + end + + test "admin cannot remove themselves" do + sign_in @admin + assert_no_difference("User.count") do + delete settings_profile_path(user_id: @admin) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.cannot_remove_self"), flash[:alert] + assert User.find(@admin.id) + end + + test "non-admin cannot remove members" do + sign_in @member + assert_no_difference("User.count") do + delete settings_profile_path(user_id: @admin) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.not_authorized"), flash[:alert] + assert User.find(@admin.id) + end end diff --git a/test/fixtures/invitations.yml b/test/fixtures/invitations.yml index 12ae9a05..09cd96f7 100644 --- a/test/fixtures/invitations.yml +++ b/test/fixtures/invitations.yml @@ -17,3 +17,13 @@ two: created_at: <%= Time.current %> updated_at: <%= Time.current %> expires_at: <%= 3.days.from_now %> + +other_family: + email: "other@example.com" + token: "valid-token-789" + role: "member" + inviter: empty + family: empty + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %> From b78fd1d7558a109cc5f50352d271ead307d67c6c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 30 Jan 2025 14:17:25 -0500 Subject: [PATCH 042/509] Temporarily disable txn logos for performance --- app/views/account/transactions/_transaction.html.erb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index fcfdb29c..7564e7ac 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -12,11 +12,7 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> - <% if transaction.merchant&.icon_url %> - <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> - <% else %> - <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> - <% end %> + <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %>
From ded42a8c33b57f5370fb43b577e71cd31310ef21 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 30 Jan 2025 15:31:16 -0500 Subject: [PATCH 043/509] Add back txn logos This reverts commit b78fd1d7558a109cc5f50352d271ead307d67c6c. --- app/views/account/transactions/_transaction.html.erb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 7564e7ac..fcfdb29c 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -12,7 +12,11 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> - <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> + <% if transaction.merchant&.icon_url %> + <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> + <% else %> + <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> + <% end %>
From ad5b0b8b7d1e6bf354be0111f992b6948cd80dac Mon Sep 17 00:00:00 2001 From: Julien Bertazzo Lambert <42924425+JLambertazzo@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:49:31 -0500 Subject: [PATCH 044/509] Ensure Consistent Category Colors (#1722) * feat: add validation to require consistent category color * feat: reflect color requirement in new category form * refactor: move logic inline over shared component * rubocop * tests: fix breaking and add case for new validation * feat: hide color selector when parent category selected * feat: override color with parent color in model * tests: remove case for unnecessary validation --------- Signed-off-by: Julien Bertazzo Lambert <42924425+JLambertazzo@users.noreply.github.com> --- app/javascript/controllers/color_avatar_controller.js | 8 +++++++- app/models/category.rb | 8 ++++++++ app/views/categories/_form.html.erb | 5 +++-- test/models/category_test.rb | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/color_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js index 276ae023..41b7568b 100644 --- a/app/javascript/controllers/color_avatar_controller.js +++ b/app/javascript/controllers/color_avatar_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="color-avatar" // Used by the transaction merchant form to show a preview of what the avatar will look like export default class extends Controller { - static targets = ["name", "avatar"]; + static targets = ["name", "avatar", "selection"]; connect() { this.nameTarget.addEventListener("input", this.handleNameChange); @@ -25,4 +25,10 @@ export default class extends Controller { this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; this.avatarTarget.style.color = color; } + + handleParentChange(e) { + const parent = e.currentTarget.value; + const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible" + this.selectionTarget.style.visibility = visibility + } } diff --git a/app/models/category.rb b/app/models/category.rb index d0ccaa60..76a83866 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -14,6 +14,8 @@ class Category < ApplicationRecord validate :category_level_limit validate :nested_category_matches_parent_classification + before_create :inherit_color_from_parent + scope :alphabetically, -> { order(:name) } scope :roots, -> { where(parent_id: nil) } scope :incomes, -> { where(classification: "income") } @@ -85,6 +87,12 @@ class Category < ApplicationRecord end end + def inherit_color_from_parent + if subcategory? + self.color = parent.color + end + end + def replace_and_destroy!(replacement) transaction do transactions.update_all category_id: replacement&.id diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 884d5509..fd9a8513 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -7,7 +7,7 @@ <%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
-
+
<% Category::COLORS.each do |color| %>
diff --git a/test/models/category_test.rb b/test/models/category_test.rb index 0f29843d..e6dccd84 100644 --- a/test/models/category_test.rb +++ b/test/models/category_test.rb @@ -25,7 +25,7 @@ class CategoryTest < ActiveSupport::TestCase category = categories(:subcategory) error = assert_raises(ActiveRecord::RecordInvalid) do - category.subcategories.create!(name: "Invalid category", color: "#000", family: @family) + category.subcategories.create!(name: "Invalid category", family: @family) end assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message From 46e86a9a11995921290c9ebe638aa099a3f3c0ee Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 31 Jan 2025 10:34:20 -0600 Subject: [PATCH 045/509] Pass in user role to Intercom --- config/initializers/intercom.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index c0d9220b..e9863abb 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -54,7 +54,8 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. # config.user.custom_data = { family_id: Proc.new { Current.family.id }, - name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email } + name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email }, + role: Proc.new { Current.user.role } } # == Current company method/variable From 41873de11d1e05e52bf5b03851b5494a30004ff5 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 31 Jan 2025 11:29:49 -0600 Subject: [PATCH 046/509] Allow users to update their email address (#1745) * Change email address * Email confirmation * Email change test * Lint * Schema reset * Set test email sender * Select specific user fixture * Refactor/cleanup * Remove unused email_confirmation_token * Current user would never be true * Fix translation test failures --- .../email_confirmations_controller.rb | 18 +++++++++++++ .../settings/hostings_controller.rb | 6 ++++- app/controllers/users_controller.rb | 25 ++++++++++++++--- app/helpers/email_confirmations_helper.rb | 2 ++ app/mailers/email_confirmation_mailer.rb | 15 +++++++++++ app/models/setting.rb | 2 ++ app/models/user.rb | 27 ++++++++++++++++++- .../confirmation_email.html.erb | 7 +++++ .../confirmation_email.text.erb | 9 +++++++ .../hostings/_invite_code_settings.html.erb | 14 ++++++++++ app/views/settings/profiles/show.html.erb | 8 +++++- config/environments/test.rb | 6 +++++ config/locales/views/accounts/en.yml | 4 +++ .../views/email_confirmation_mailer/en.yml | 9 +++++++ config/locales/views/settings/en.yml | 4 +++ config/locales/views/settings/hostings/en.yml | 4 ++- config/locales/views/users/en.yml | 4 ++- config/routes.rb | 1 + ...0191533_add_email_confirmation_to_users.rb | 9 +++++++ ...e_email_confirmation_sent_at_from_users.rb | 5 ++++ ...ove_email_confirmation_token_from_users.rb | 6 +++++ db/schema.rb | 3 ++- .../email_confirmations_controller_test.rb | 12 +++++++++ .../transactions_controller_test.rb | 4 +-- test/fixtures/users.yml | 11 +++++++- .../mailers/email_confirmation_mailer_test.rb | 14 ++++++++++ .../email_confirmation_mailer_preview.rb | 7 +++++ test/models/user_test.rb | 4 +-- 28 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 app/controllers/email_confirmations_controller.rb create mode 100644 app/helpers/email_confirmations_helper.rb create mode 100644 app/mailers/email_confirmation_mailer.rb create mode 100644 app/views/email_confirmation_mailer/confirmation_email.html.erb create mode 100644 app/views/email_confirmation_mailer/confirmation_email.text.erb create mode 100644 config/locales/views/email_confirmation_mailer/en.yml create mode 100644 db/migrate/20250130191533_add_email_confirmation_to_users.rb create mode 100644 db/migrate/20250130214500_remove_email_confirmation_sent_at_from_users.rb create mode 100644 db/migrate/20250131171943_remove_email_confirmation_token_from_users.rb create mode 100644 test/controllers/email_confirmations_controller_test.rb create mode 100644 test/mailers/email_confirmation_mailer_test.rb create mode 100644 test/mailers/previews/email_confirmation_mailer_preview.rb diff --git a/app/controllers/email_confirmations_controller.rb b/app/controllers/email_confirmations_controller.rb new file mode 100644 index 00000000..eb5c3755 --- /dev/null +++ b/app/controllers/email_confirmations_controller.rb @@ -0,0 +1,18 @@ +class EmailConfirmationsController < ApplicationController + skip_before_action :set_request_details, only: :new + skip_authentication only: :new + + def new + # Returns nil if the token is invalid OR expired + @user = User.find_by_token_for(:email_confirmation, params[:token]) + + if @user&.unconfirmed_email && @user&.update( + email: @user.unconfirmed_email, + unconfirmed_email: nil + ) + redirect_to new_session_path, notice: t(".success_login") + else + redirect_to root_path, alert: t(".invalid_token") + end + end +end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 222ae018..aae6513c 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -22,6 +22,10 @@ class Settings::HostingsController < SettingsController Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup] end + if hosting_params.key?(:require_email_confirmation) + Setting.require_email_confirmation = hosting_params[:require_email_confirmation] + end + if hosting_params.key?(:synth_api_key) Setting.synth_api_key = hosting_params[:synth_api_key] end @@ -34,7 +38,7 @@ class Settings::HostingsController < SettingsController private def hosting_params - params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key) + params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key) end def raise_if_not_self_hosted diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 55b75581..5bb4f45f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -4,10 +4,23 @@ class UsersController < ApplicationController def update @user = Current.user - @user.update!(user_params.except(:redirect_to, :delete_profile_image)) - @user.profile_image.purge if should_purge_profile_image? + if email_changed? + if @user.initiate_email_change(user_params[:email]) + if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation + handle_redirect(t(".success")) + else + redirect_to settings_profile_path, notice: t(".email_change_initiated") + end + else + error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed") + redirect_to settings_profile_path, alert: error_message + end + else + @user.update!(user_params.except(:redirect_to, :delete_profile_image)) + @user.profile_image.purge if should_purge_profile_image? - handle_redirect(t(".success")) + handle_redirect(t(".success")) + end end def destroy @@ -38,9 +51,13 @@ class UsersController < ApplicationController user_params[:profile_image].blank? end + def email_changed? + user_params[:email].present? && user_params[:email] != @user.email + end + def user_params params.require(:user).permit( - :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/helpers/email_confirmations_helper.rb b/app/helpers/email_confirmations_helper.rb new file mode 100644 index 00000000..c5f58449 --- /dev/null +++ b/app/helpers/email_confirmations_helper.rb @@ -0,0 +1,2 @@ +module EmailConfirmationsHelper +end diff --git a/app/mailers/email_confirmation_mailer.rb b/app/mailers/email_confirmation_mailer.rb new file mode 100644 index 00000000..3ad99b96 --- /dev/null +++ b/app/mailers/email_confirmation_mailer.rb @@ -0,0 +1,15 @@ +class EmailConfirmationMailer < ApplicationMailer + # Subject can be set in your I18n file at config/locales/en.yml + # with the following lookup: + # + # en.email_confirmation_mailer.confirmation_email.subject + # + def confirmation_email + @user = params[:user] + @subject = t(".subject") + @cta = t(".cta") + @confirmation_url = new_email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) + + mail to: @user.unconfirmed_email, subject: @subject + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index d576fbea..41355fee 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -20,4 +20,6 @@ class Setting < RailsSettings::Base field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] field :require_invite_for_signup, type: :boolean, default: false + + field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" end diff --git a/app/models/user.rb b/app/models/user.rb index 0d50ba87..92171040 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,9 +7,10 @@ class User < ApplicationRecord has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy accepts_nested_attributes_for :family, update_only: true - validates :email, presence: true, uniqueness: true + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validate :ensure_valid_profile_image normalizes :email, with: ->(email) { email.strip.downcase } + normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase } normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } @@ -25,6 +26,30 @@ class User < ApplicationRecord password_salt&.last(10) end + generates_token_for :email_confirmation, expires_in: 1.day do + unconfirmed_email + end + + def pending_email_change? + unconfirmed_email.present? + end + + def initiate_email_change(new_email) + return false if new_email == email + return false if new_email == unconfirmed_email + + if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation + update(email: new_email) + else + if update(unconfirmed_email: new_email) + EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later + true + else + false + end + end + end + def request_impersonation_for(user_id) impersonated = User.find(user_id) impersonator_support_sessions.create!(impersonated: impersonated) diff --git a/app/views/email_confirmation_mailer/confirmation_email.html.erb b/app/views/email_confirmation_mailer/confirmation_email.html.erb new file mode 100644 index 00000000..be87ccd0 --- /dev/null +++ b/app/views/email_confirmation_mailer/confirmation_email.html.erb @@ -0,0 +1,7 @@ +

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

+ +

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

+ +<%= link_to @cta, @confirmation_url, class: "button" %> + + \ No newline at end of file diff --git a/app/views/email_confirmation_mailer/confirmation_email.text.erb b/app/views/email_confirmation_mailer/confirmation_email.text.erb new file mode 100644 index 00000000..8f5fa14b --- /dev/null +++ b/app/views/email_confirmation_mailer/confirmation_email.text.erb @@ -0,0 +1,9 @@ +EmailConfirmation#confirmation_email + +<%= t(".greeting") %> + +<%= t(".body") %> + +<%= t(".cta") %>: <%= @confirmation_url %> + +<%= t(".expiry_notice", hours: 24) %> \ No newline at end of file diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index e9889d75..de3b1e22 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -13,6 +13,20 @@ <% end %>
+
+
+

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

+

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

+
+ + <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> +
+ <%= form.check_box :require_email_confirmation, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %> + <%= form.label :require_email_confirmation, " ".html_safe, class: "maybe-switch" %> +
+ <% end %> +
+ <% if Setting.require_invite_for_signup %>
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 5f0d5e10..d0ffb6da 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -5,10 +5,16 @@

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

<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %> - <%= styled_form_with model: @user, class: "space-y-4" do |form| %> + <%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4" do |form| %> <%= render "settings/user_avatar_field", form: form, user: @user %>
+ <%= form.email_field :email, placeholder: t(".email"), label: t(".email") %> + <% if @user.unconfirmed_email.present? %> +

+ You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect. +

+ <% end %>
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %> <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %> diff --git a/config/environments/test.rb b/config/environments/test.rb index 37637b90..23bd03f3 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -18,10 +18,14 @@ Rails.application.configure do config.eager_load = ENV["CI"].present? # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } + # Set default sender email for tests + ENV["EMAIL_SENDER"] = "hello@maybefinance.com" + # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false @@ -69,4 +73,6 @@ Rails.application.configure do config.active_record.encryption.encrypt_fixtures = true config.autoload_paths += %w[test/support] + + config.action_mailer.default_url_options = { host: "example.com" } end diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 94aa32b2..9162acdd 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -73,3 +73,7 @@ en: or entering manually. update: success: "%{type} account updated" + email_confirmations: + new: + success_login: "Your email has been confirmed. Please log in with your new email address." + invalid_token: "Invalid or expired confirmation link." diff --git a/config/locales/views/email_confirmation_mailer/en.yml b/config/locales/views/email_confirmation_mailer/en.yml new file mode 100644 index 00000000..110746a8 --- /dev/null +++ b/config/locales/views/email_confirmation_mailer/en.yml @@ -0,0 +1,9 @@ +--- +en: + email_confirmation_mailer: + confirmation_email: + subject: "Maybe: Confirm your email change" + greeting: "Hello!" + body: "You recently requested to change your email address. Click the button below to confirm this change." + cta: "Confirm email change" + expiry_notice: "This link will expire in %{hours} hours." \ No newline at end of file diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 5bfa97d4..c082139c 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -79,6 +79,7 @@ en: invitation_link: Invitation link invite_member: Add member last_name: Last Name + email: Email page_title: Account pending: Pending profile_subtitle: Customize how you appear on Maybe @@ -87,3 +88,6 @@ en: user_avatar_field: accepted_formats: JPG or PNG. 5MB max. choose: Choose + users: + update: + success: Profile updated successfully diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 90a89fd7..06334a31 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -7,7 +7,9 @@ en: so via an invite code generate_tokens: Generate new code generated_tokens: Generated codes - title: Require invite code for new sign ups + title: Require invite code for signup + email_confirmation_title: Require email confirmation + email_confirmation_description: When enabled, users must confirm their email address when changing it. provider_settings: description: Configure settings for your hosting provider render_deploy_hook_label: Render Deploy Hook URL diff --git a/config/locales/views/users/en.yml b/config/locales/views/users/en.yml index 6b488278..5eaf6f63 100644 --- a/config/locales/views/users/en.yml +++ b/config/locales/views/users/en.yml @@ -4,4 +4,6 @@ en: destroy: success: Your account has been deleted. update: - success: Your profile has been updated. + success: "Your profile has been updated." + email_change_initiated: "Please check your new email address for confirmation instructions." + email_change_failed: "Failed to change email address." diff --git a/config/routes.rb b/config/routes.rb index d83ac11b..33d534a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ Rails.application.routes.draw do resources :sessions, only: %i[new create destroy] resource :password_reset, only: %i[new create edit update] resource :password, only: %i[edit update] + resource :email_confirmation, only: :new resources :users, only: %i[update destroy] diff --git a/db/migrate/20250130191533_add_email_confirmation_to_users.rb b/db/migrate/20250130191533_add_email_confirmation_to_users.rb new file mode 100644 index 00000000..ac929b48 --- /dev/null +++ b/db/migrate/20250130191533_add_email_confirmation_to_users.rb @@ -0,0 +1,9 @@ +class AddEmailConfirmationToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :unconfirmed_email, :string + add_column :users, :email_confirmation_token, :string + add_column :users, :email_confirmation_sent_at, :datetime + + add_index :users, :email_confirmation_token, unique: true + end +end diff --git a/db/migrate/20250130214500_remove_email_confirmation_sent_at_from_users.rb b/db/migrate/20250130214500_remove_email_confirmation_sent_at_from_users.rb new file mode 100644 index 00000000..89533213 --- /dev/null +++ b/db/migrate/20250130214500_remove_email_confirmation_sent_at_from_users.rb @@ -0,0 +1,5 @@ +class RemoveEmailConfirmationSentAtFromUsers < ActiveRecord::Migration[7.2] + def change + remove_column :users, :email_confirmation_sent_at, :datetime + end +end diff --git a/db/migrate/20250131171943_remove_email_confirmation_token_from_users.rb b/db/migrate/20250131171943_remove_email_confirmation_token_from_users.rb new file mode 100644 index 00000000..db1db8b8 --- /dev/null +++ b/db/migrate/20250131171943_remove_email_confirmation_token_from_users.rb @@ -0,0 +1,6 @@ +class RemoveEmailConfirmationTokenFromUsers < ActiveRecord::Migration[7.2] + def change + remove_index :users, :email_confirmation_token + remove_column :users, :email_confirmation_token, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 54a65580..8efce7d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_01_28_203303) do +ActiveRecord::Schema[7.2].define(version: 2025_01_31_171943) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -661,6 +661,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_28_203303) do t.string "role", default: "member", null: false t.boolean "active", default: true, null: false t.datetime "onboarded_at" + t.string "unconfirmed_email" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" end diff --git a/test/controllers/email_confirmations_controller_test.rb b/test/controllers/email_confirmations_controller_test.rb new file mode 100644 index 00000000..beee4f73 --- /dev/null +++ b/test/controllers/email_confirmations_controller_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest + test "should get confirm" do + user = users(:new_email) + user.update!(unconfirmed_email: "new@example.com") + token = user.generate_token_for(:email_confirmation) + + get new_email_confirmation_path(token: token) + assert_redirected_to new_session_path + end +end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index e4920819..73492e6e 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -10,7 +10,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest test "transaction count represents filtered total" do family = families(:empty) - sign_in family.users.first + sign_in users(:empty) account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new 3.times do @@ -32,7 +32,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest test "can paginate" do family = families(:empty) - sign_in family.users.first + sign_in users(:empty) account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new 11.times do diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index d7e7444d..246fdf56 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -30,4 +30,13 @@ family_member: last_name: Dylan email: jakobdylan@yahoo.com password_digest: <%= BCrypt::Password.create('password') %> - onboarded_at: <%= 3.days.ago %> \ No newline at end of file + onboarded_at: <%= 3.days.ago %> + +new_email: + family: empty + first_name: Test + last_name: User + email: user@example.com + unconfirmed_email: new@example.com + password_digest: <%= BCrypt::Password.create('password123') %> + onboarded_at: <%= Time.current %> \ No newline at end of file diff --git a/test/mailers/email_confirmation_mailer_test.rb b/test/mailers/email_confirmation_mailer_test.rb new file mode 100644 index 00000000..2727cfb9 --- /dev/null +++ b/test/mailers/email_confirmation_mailer_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class EmailConfirmationMailerTest < ActionMailer::TestCase + test "confirmation_email" do + user = users(:new_email) + user.unconfirmed_email = "new@example.com" + + mail = EmailConfirmationMailer.with(user: user).confirmation_email + assert_equal I18n.t("email_confirmation_mailer.confirmation_email.subject"), mail.subject + assert_equal [ user.unconfirmed_email ], mail.to + assert_equal [ "hello@maybefinance.com" ], mail.from + assert_match "confirm", mail.body.encoded + end +end diff --git a/test/mailers/previews/email_confirmation_mailer_preview.rb b/test/mailers/previews/email_confirmation_mailer_preview.rb new file mode 100644 index 00000000..36aa4648 --- /dev/null +++ b/test/mailers/previews/email_confirmation_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/email_confirmation_mailer +class EmailConfirmationMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/email_confirmation_mailer/confirmation_email + def confirmation_email + EmailConfirmationMailer.confirmation_email + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 582fc2e8..dde2c44e 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -38,8 +38,8 @@ class UserTest < ActiveSupport::TestCase end test "email address is normalized" do - @user.update!(email: " User@ExAMPle.CoM ") - assert_equal "user@example.com", @user.reload.email + @user.update!(email: " UNIQUE-User@ExAMPle.CoM ") + assert_equal "unique-user@example.com", @user.reload.email end test "display name" do From 4bf72506d502d96937e6a30d940a361f53fe3339 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 31 Jan 2025 12:13:58 -0600 Subject: [PATCH 047/509] Initial pass at Plaid EU (#1555) * Initial pass at Plaid EU * Add EU support to Plaid Items * Lint * Temp fix for rubocop isseus * Merge cleanup * Pass in region and get tests passing * Use absolute path for translation --------- Signed-off-by: Josh Pigford --- .env.example | 4 +++- .../concerns/accountable_resource.rb | 3 ++- app/controllers/plaid_items_controller.rb | 3 ++- app/javascript/controllers/plaid_controller.js | 2 ++ app/models/concerns/plaidable.rb | 13 +++++++++++-- app/models/family.rb | 18 ++++++++++++++---- app/models/plaid_item.rb | 10 +++++++--- app/models/provider/plaid.rb | 12 ++++++++++-- .../accounts/new/_method_selector.html.erb | 13 ++++++++++++- config/initializers/plaid.rb | 8 ++++++++ config/locales/views/accounts/en.yml | 1 + .../20241219151540_add_region_to_plaid_item.rb | 5 +++++ db/schema.rb | 1 + .../controllers/plaid_items_controller_test.rb | 1 + test/models/plaid_item_test.rb | 8 ++------ 15 files changed, 81 insertions(+), 21 deletions(-) create mode 100644 db/migrate/20241219151540_add_region_to_plaid_item.rb diff --git a/.env.example b/.env.example index ef90f4a3..5253039c 100644 --- a/.env.example +++ b/.env.example @@ -118,4 +118,6 @@ STRIPE_WEBHOOK_SECRET= # PLAID_CLIENT_ID= PLAID_SECRET= -PLAID_ENV= \ No newline at end of file +PLAID_ENV= +PLAID_EU_CLIENT_ID= +PLAID_EU_SECRET= diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 1cdf7baa..f149adc3 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -55,7 +55,8 @@ module AccountableResource @link_token = Current.family.get_link_token( webhooks_url: webhooks_url, redirect_url: accounts_url, - accountable_type: accountable_type.name + accountable_type: accountable_type.name, + region: Current.family.country.to_s.downcase == "us" ? :us : :eu ) end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index c0ac89ad..64537896 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -5,6 +5,7 @@ class PlaidItemsController < ApplicationController Current.family.plaid_items.create_from_public_token( plaid_item_params[:public_token], item_name: item_name, + region: plaid_item_params[:region] ) redirect_to accounts_path, notice: t(".success") @@ -29,7 +30,7 @@ class PlaidItemsController < ApplicationController end def plaid_item_params - params.require(:plaid_item).permit(:public_token, metadata: {}) + params.require(:plaid_item).permit(:public_token, :region, metadata: {}) end def item_name diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index 14b0c182..5f4f1d29 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static values = { linkToken: String, + region: { type: String, default: "us" } }; open() { @@ -31,6 +32,7 @@ export default class extends Controller { plaid_item: { public_token: public_token, metadata: metadata, + region: this.regionValue }, }), }).then((response) => { diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb index ddecd893..838b4837 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/concerns/plaidable.rb @@ -5,10 +5,19 @@ module Plaidable def plaid_provider Provider::Plaid.new if Rails.application.config.plaid end + + def plaid_eu_provider + Provider::Plaid.new if Rails.application.config.plaid_eu + end + + def plaid_provider_for(plaid_item) + return nil unless plaid_item + plaid_item.eu? ? plaid_eu_provider : plaid_provider + end end private - def plaid_provider - self.class.plaid_provider + def plaid_provider_for(plaid_item) + self.class.plaid_provider_for(plaid_item) end end diff --git a/app/models/family.rb b/app/models/family.rb index f2f1b530..4b36fcd8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,3 +1,4 @@ +# rubocop:disable Layout/ElseAlignment, Layout/IndentationWidth class Family < ApplicationRecord include Plaidable, Syncable @@ -47,14 +48,22 @@ class Family < ApplicationRecord super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?) end - def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil) - return nil unless plaid_provider + def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us) + provider = case region + when :eu + self.class.plaid_eu_provider + else + self.class.plaid_provider + end - plaid_provider.get_link_token( + return nil unless provider + + provider.get_link_token( user_id: id, webhooks_url: webhooks_url, redirect_url: redirect_url, - accountable_type: accountable_type + accountable_type: accountable_type, + eu: region == :eu ).link_token end @@ -229,3 +238,4 @@ class Family < ApplicationRecord ) end end +# rubocop:enable Layout/ElseAlignment, Layout/IndentationWidth diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index cbbe14bb..a41d96a9 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,6 +1,8 @@ class PlaidItem < ApplicationRecord include Plaidable, Syncable + enum :plaid_region, { us: "us", eu: "eu" } + if Rails.application.credentials.active_record_encryption.present? encrypts :access_token, deterministic: true end @@ -19,13 +21,14 @@ class PlaidItem < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } class << self - def create_from_public_token(token, item_name:) + def create_from_public_token(token, item_name:, region: "us") response = plaid_provider.exchange_public_token(token) new_plaid_item = create!( name: item_name, plaid_id: response.item_id, access_token: response.access_token, + plaid_region: region ) new_plaid_item.sync_later @@ -56,10 +59,11 @@ class PlaidItem < ApplicationRecord private def fetch_and_load_plaid_data data = {} - item = plaid_provider.get_item(access_token).item + provider = plaid_provider_for(self) + item = provider.get_item(access_token).item update!(available_products: item.available_products, billed_products: item.billed_products) - fetched_accounts = plaid_provider.get_item_accounts(self).accounts + fetched_accounts = provider.get_item_accounts(self).accounts data[:accounts] = fetched_accounts || [] internal_plaid_accounts = fetched_accounts.map do |account| diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 7b95cb88..813102a0 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -68,13 +68,13 @@ class Provider::Plaid @client = self.class.client end - def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil) + def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, eu: false) request = Plaid::LinkTokenCreateRequest.new({ user: { client_user_id: user_id }, client_name: "Maybe Finance", products: [ get_primary_product(accountable_type) ], additional_consented_products: get_additional_consented_products(accountable_type), - country_codes: [ "US", "CA" ], + country_codes: get_country_codes(eu), language: "en", webhook: webhooks_url, redirect_uri: redirect_url, @@ -198,4 +198,12 @@ class Provider::Plaid def get_additional_consented_products(accountable_type) MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ] end + + def get_country_codes(eu) + if eu + [ "ES", "NL", "FR", "IE", "DE", "IT", "PL", "DK", "NO", "SE", "EE", "LT", "LV", "PT", "BE" ] # EU supported countries + else + [ "US", "CA" ] # US + CA only + end + end end diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 4c32ede4..0ec22cfd 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -10,12 +10,23 @@ <% end %> <% if link_token.present? %> + <%# Default US-only Link %> + + <%# EU Link %> + <% unless Current.family.country == "US" %> + + <% end %> <% end %>
<% end %> diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb index b0631110..1925158a 100644 --- a/config/initializers/plaid.rb +++ b/config/initializers/plaid.rb @@ -1,5 +1,6 @@ Rails.application.configure do config.plaid = nil + config.plaid_eu = nil if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present? config.plaid = Plaid::Configuration.new @@ -7,4 +8,11 @@ Rails.application.configure do config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] end + + if ENV["PLAID_EU_CLIENT_ID"].present? && ENV["PLAID_EU_SECRET"].present? + config.plaid_eu = Plaid::Configuration.new + config.plaid_eu.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] + config.plaid_eu.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_EU_CLIENT_ID"] + config.plaid_eu.api_key["PLAID-SECRET"] = ENV["PLAID_EU_SECRET"] + end end diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 9162acdd..61029610 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -30,6 +30,7 @@ en: import_accounts: Import accounts method_selector: connected_entry: Link account + connected_entry_eu: Link EU account manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? diff --git a/db/migrate/20241219151540_add_region_to_plaid_item.rb b/db/migrate/20241219151540_add_region_to_plaid_item.rb new file mode 100644 index 00000000..55e96075 --- /dev/null +++ b/db/migrate/20241219151540_add_region_to_plaid_item.rb @@ -0,0 +1,5 @@ +class AddRegionToPlaidItem < ActiveRecord::Migration[7.2] + def change + add_column :plaid_items, :plaid_region, :string, null: false, default: "us" + end +end diff --git a/db/schema.rb b/db/schema.rb index 8efce7d1..4a507949 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -516,6 +516,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_31_171943) do t.string "available_products", default: [], array: true t.string "billed_products", default: [], array: true t.datetime "last_synced_at" + t.string "plaid_region", default: "us", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 7f9a3afe..78636757 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -21,6 +21,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest post plaid_items_url, params: { plaid_item: { public_token: public_token, + region: "us", metadata: { institution: { name: "Plaid Item Name" } } } } diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index e18b1785..8d680707 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -9,9 +9,7 @@ class PlaidItemTest < ActiveSupport::TestCase test "removes plaid item when destroyed" do @plaid_provider = mock - - PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) - + @plaid_item.stubs(:plaid_provider).returns(@plaid_provider) @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once assert_difference "PlaidItem.count", -1 do @@ -21,9 +19,7 @@ class PlaidItemTest < ActiveSupport::TestCase test "if plaid item not found, silently continues with deletion" do @plaid_provider = mock - - PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) - + @plaid_item.stubs(:plaid_provider).returns(@plaid_provider) @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found")) assert_difference "PlaidItem.count", -1 do From 53f4b32c330b107f5295105a78cb660b74a8deee Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 31 Jan 2025 17:04:26 -0500 Subject: [PATCH 048/509] Fix EU plaid flow (#1761) * Fix EU plaid flow * Fix failing tests --- .../concerns/accountable_resource.rb | 13 ++++++++-- .../controllers/plaid_controller.js | 20 +++++++------- app/models/account/balance_calculator.rb | 2 +- app/models/account/syncer.rb | 14 ++++++---- app/models/concerns/plaidable.rb | 19 ++++++++------ app/models/family.rb | 20 +++++++------- app/models/plaid_item.rb | 9 +++---- app/models/provider/plaid.rb | 25 +++++++----------- .../accounts/new/_method_selector.html.erb | 26 +++++++++---------- app/views/credit_cards/new.html.erb | 2 +- app/views/cryptos/new.html.erb | 2 +- app/views/depositories/new.html.erb | 2 +- .../confirmation_email.html.erb | 2 +- .../confirmation_email.text.erb | 2 +- app/views/investments/new.html.erb | 2 +- app/views/loans/new.html.erb | 2 +- app/views/settings/profiles/show.html.erb | 4 +-- .../plaid_items_controller_test.rb | 7 +++-- .../accountable_resource_interface_test.rb | 4 +-- 19 files changed, 91 insertions(+), 86 deletions(-) diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index f149adc3..675c3d93 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -52,12 +52,21 @@ module AccountableResource private def set_link_token - @link_token = Current.family.get_link_token( + @us_link_token = Current.family.get_link_token( webhooks_url: webhooks_url, redirect_url: accounts_url, accountable_type: accountable_type.name, - region: Current.family.country.to_s.downcase == "us" ? :us : :eu + region: :us ) + + if Current.family.eu? + @eu_link_token = Current.family.get_link_token( + webhooks_url: webhooks_url, + redirect_url: accounts_url, + accountable_type: accountable_type.name, + region: :eu + ) + end end def webhooks_url diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index 5f4f1d29..cf1f71cb 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static values = { linkToken: String, - region: { type: String, default: "us" } + region: { type: String, default: "us" }, }; open() { @@ -19,7 +19,7 @@ export default class extends Controller { handler.open(); } - handleSuccess(public_token, metadata) { + handleSuccess = (public_token, metadata) => { window.location.href = "/accounts"; fetch("/plaid_items", { @@ -32,7 +32,7 @@ export default class extends Controller { plaid_item: { public_token: public_token, metadata: metadata, - region: this.regionValue + region: this.regionValue, }, }), }).then((response) => { @@ -40,17 +40,17 @@ export default class extends Controller { window.location.href = response.url; } }); - } + }; - handleExit(err, metadata) { + handleExit = (err, metadata) => { // no-op - } + }; - handleEvent(eventName, metadata) { + handleEvent = (eventName, metadata) => { // no-op - } + }; - handleLoad() { + handleLoad = () => { // no-op - } + }; } diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb index 55e094ed..d5b3b3d1 100644 --- a/app/models/account/balance_calculator.rb +++ b/app/models/account/balance_calculator.rb @@ -11,7 +11,7 @@ class Account::BalanceCalculator holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) balance.balance = balance.balance + holdings_value balance - end + end.compact end private diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index d5a5ec84..37074aa7 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -76,29 +76,33 @@ class Account::Syncer exchange_rates = ExchangeRate.find_rates( from: from_currency, to: to_currency, - start_date: balances.first.date + start_date: balances.min_by(&:date).date ) converted_balances = balances.map do |balance| exchange_rate = exchange_rates.find { |er| er.date == balance.date } + next unless exchange_rate.present? + account.balances.build( date: balance.date, balance: exchange_rate.rate * balance.balance, currency: to_currency - ) if exchange_rate.present? - end + ) + end.compact converted_holdings = holdings.map do |holding| exchange_rate = exchange_rates.find { |er| er.date == holding.date } + next unless exchange_rate.present? + account.holdings.build( security: holding.security, date: holding.date, amount: exchange_rate.rate * holding.amount, currency: to_currency - ) if exchange_rate.present? - end + ) + end.compact Account.transaction do load_balances(converted_balances) diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb index 838b4837..062eab8e 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/concerns/plaidable.rb @@ -2,22 +2,25 @@ module Plaidable extend ActiveSupport::Concern class_methods do - def plaid_provider - Provider::Plaid.new if Rails.application.config.plaid + def plaid_us_provider + Provider::Plaid.new(Rails.application.config.plaid, :us) if Rails.application.config.plaid end def plaid_eu_provider - Provider::Plaid.new if Rails.application.config.plaid_eu + Provider::Plaid.new(Rails.application.config.plaid_eu, :eu) if Rails.application.config.plaid_eu end - def plaid_provider_for(plaid_item) - return nil unless plaid_item - plaid_item.eu? ? plaid_eu_provider : plaid_provider + def plaid_provider_for_region(region) + region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider end end private - def plaid_provider_for(plaid_item) - self.class.plaid_provider_for(plaid_item) + def eu? + raise "eu? is not implemented for #{self.class.name}" + end + + def plaid_provider + eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider end end diff --git a/app/models/family.rb b/app/models/family.rb index 4b36fcd8..7ac41f25 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,3 @@ -# rubocop:disable Layout/ElseAlignment, Layout/IndentationWidth class Family < ApplicationRecord include Plaidable, Syncable @@ -48,22 +47,22 @@ class Family < ApplicationRecord super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?) end - def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us) - provider = case region - when :eu - self.class.plaid_eu_provider - else - self.class.plaid_provider - end + def eu? + country != "US" && country != "CA" + end - return nil unless provider + def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us) + provider = if region.to_sym == :eu + self.class.plaid_eu_provider + else + self.class.plaid_us_provider + end provider.get_link_token( user_id: id, webhooks_url: webhooks_url, redirect_url: redirect_url, accountable_type: accountable_type, - eu: region == :eu ).link_token end @@ -238,4 +237,3 @@ class Family < ApplicationRecord ) end end -# rubocop:enable Layout/ElseAlignment, Layout/IndentationWidth diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index a41d96a9..9ac1e002 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -21,8 +21,8 @@ class PlaidItem < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } class << self - def create_from_public_token(token, item_name:, region: "us") - response = plaid_provider.exchange_public_token(token) + def create_from_public_token(token, item_name:, region:) + response = plaid_provider_for_region(region).exchange_public_token(token) new_plaid_item = create!( name: item_name, @@ -59,11 +59,10 @@ class PlaidItem < ApplicationRecord private def fetch_and_load_plaid_data data = {} - provider = plaid_provider_for(self) - item = provider.get_item(access_token).item + item = plaid_provider.get_item(access_token).item update!(available_products: item.available_products, billed_products: item.billed_products) - fetched_accounts = provider.get_item_accounts(self).accounts + fetched_accounts = plaid_provider.get_item_accounts(self).accounts data[:accounts] = fetched_accounts || [] internal_plaid_accounts = fetched_accounts.map do |account| diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 813102a0..877a955f 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -1,5 +1,5 @@ class Provider::Plaid - attr_reader :client + attr_reader :client, :region MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 @@ -54,27 +54,22 @@ class Provider::Plaid actual_hash = Digest::SHA256.hexdigest(raw_body) raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash) end - - def client - api_client = Plaid::ApiClient.new( - Rails.application.config.plaid - ) - - Plaid::PlaidApi.new(api_client) - end end - def initialize - @client = self.class.client + def initialize(config, region) + @client = Plaid::PlaidApi.new( + Plaid::ApiClient.new(config) + ) + @region = region end - def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, eu: false) + def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil) request = Plaid::LinkTokenCreateRequest.new({ user: { client_user_id: user_id }, client_name: "Maybe Finance", products: [ get_primary_product(accountable_type) ], additional_consented_products: get_additional_consented_products(accountable_type), - country_codes: get_country_codes(eu), + country_codes: country_codes, language: "en", webhook: webhooks_url, redirect_uri: redirect_url, @@ -199,8 +194,8 @@ class Provider::Plaid MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ] end - def get_country_codes(eu) - if eu + def country_codes + if region.to_sym == :eu [ "ES", "NL", "FR", "IE", "DE", "IT", "PL", "DK", "NO", "SE", "EE", "LT", "LV", "PT", "BE" ] # EU supported countries else [ "US", "CA" ] # US + CA only diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 0ec22cfd..13834f09 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,4 +1,4 @@ -<%# locals: (path:, link_token: nil) %> +<%# locals: (path:, us_link_token: nil, eu_link_token: nil) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
@@ -9,24 +9,24 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - <% if link_token.present? %> + <% if us_link_token %> <%# Default US-only Link %> - + <% end %> - <%# EU Link %> - <% unless Current.family.country == "US" %> - - <% end %> + <%# EU Link %> + <% if eu_link_token %> + <% end %>
<% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 9297f2ae..5b35dce1 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), link_token: @link_token %> + <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "credit_cards/form", account: @account, url: credit_cards_path %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index ff9e1d07..f4979c6e 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), link_token: @link_token %> + <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "cryptos/form", account: @account, url: cryptos_path %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index 9247ca2c..3ed099b3 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), link_token: @link_token %> + <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "depositories/form", account: @account, url: depositories_path %> diff --git a/app/views/email_confirmation_mailer/confirmation_email.html.erb b/app/views/email_confirmation_mailer/confirmation_email.html.erb index be87ccd0..1501a00e 100644 --- a/app/views/email_confirmation_mailer/confirmation_email.html.erb +++ b/app/views/email_confirmation_mailer/confirmation_email.html.erb @@ -4,4 +4,4 @@ <%= link_to @cta, @confirmation_url, class: "button" %> - \ No newline at end of file + diff --git a/app/views/email_confirmation_mailer/confirmation_email.text.erb b/app/views/email_confirmation_mailer/confirmation_email.text.erb index 8f5fa14b..e315baac 100644 --- a/app/views/email_confirmation_mailer/confirmation_email.text.erb +++ b/app/views/email_confirmation_mailer/confirmation_email.text.erb @@ -6,4 +6,4 @@ EmailConfirmation#confirmation_email <%= t(".cta") %>: <%= @confirmation_url %> -<%= t(".expiry_notice", hours: 24) %> \ No newline at end of file +<%= t(".expiry_notice", hours: 24) %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index 532a168c..92dc1da7 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), link_token: @link_token %> + <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "investments/form", account: @account, url: investments_path %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index 110a68bd..b09a6b32 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), link_token: @link_token %> + <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "loans/form", account: @account, url: loans_path %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index d0ffb6da..7fa30dd1 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -51,7 +51,7 @@
<% if Current.user.admin? && user != Current.user %>
- <%= button_to settings_profile_path(user_id: user), + <%= button_to settings_profile_path(user_id: user), method: :delete, class: "text-red-500 hover:text-red-700", data: { turbo_confirm: { @@ -101,7 +101,7 @@
<% end %> <% if Current.user.admin? %> - <%= button_to invitation_path(invitation), + <%= button_to invitation_path(invitation), method: :delete, class: "text-red-500 hover:text-red-700", data: { turbo_confirm: { diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 78636757..7aede85c 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -4,13 +4,12 @@ require "ostruct" class PlaidItemsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - - @plaid_provider = mock - - PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) end test "create" do + @plaid_provider = mock + PlaidItem.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider) + public_token = "public-sandbox-1234" @plaid_provider.expects(:exchange_public_token).with(public_token).returns( diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index 913854a3..1a99d63e 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -4,9 +4,7 @@ module AccountableResourceInterfaceTest extend ActiveSupport::Testing::Declarative test "shows new form" do - Plaid::PlaidApi.any_instance.stubs(:link_token_create).returns( - Plaid::LinkTokenCreateResponse.new(link_token: "test-link-token") - ) + Family.any_instance.stubs(:get_link_token).returns("test-link-token") get new_polymorphic_url(@account.accountable) assert_response :success From 2c2b600163f62bd64f35c2057d3c3d5598217e2c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 31 Jan 2025 19:08:21 -0500 Subject: [PATCH 049/509] Improve speed of transactions page (#1752) * Make demo data more realistic * Fix N+1 transactions query * Lint fixes * Totals query * Consolidate stats calcs * Fix preload * Fix filter clearing * Fix N+1 queries for family sync detection * Reduce queries for rendering transfers * Fix tests * Remove flaky test --- Gemfile | 2 + Gemfile.lock | 5 + app/controllers/pages_controller.rb | 2 +- app/controllers/transactions_controller.rb | 32 +-- app/helpers/account/entries_helper.rb | 6 +- app/models/account/entry.rb | 42 ++-- app/models/demo/generator.rb | 221 +++++++++--------- app/models/family.rb | 7 +- app/models/user.rb | 3 +- app/views/account/trades/index.html.erb | 2 +- .../transactions/_transaction.html.erb | 17 +- app/views/account/transactions/index.html.erb | 2 +- app/views/accounts/show/_activity.html.erb | 2 +- app/views/layouts/_sidebar.html.erb | 4 +- app/views/pages/dashboard.html.erb | 2 +- app/views/settings/_user_avatar.html.erb | 4 +- app/views/transactions/_summary.html.erb | 6 +- app/views/transactions/index.html.erb | 10 +- .../searches/filters/_badge.html.erb | 5 +- lib/tasks/demo_data.rake | 6 +- test/models/account/entry_test.rb | 19 +- test/system/accounts_test.rb | 5 - 22 files changed, 209 insertions(+), 195 deletions(-) diff --git a/Gemfile b/Gemfile index 933985df..180d47f6 100644 --- a/Gemfile +++ b/Gemfile @@ -28,6 +28,7 @@ gem "good_job" # Error logging gem "stackprof" +gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" @@ -67,6 +68,7 @@ group :development do gem "ruby-lsp-rails" gem "web-console" gem "faker" + gem "benchmark-ips" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 61a15574..e919b1f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,6 +101,7 @@ GEM base64 (0.2.0) bcrypt (3.1.20) benchmark (0.4.0) + benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -317,6 +318,8 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.8) + rack-mini-profiler (3.3.1) + rack (>= 1.2.0) rack-session (2.1.0) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -501,6 +504,7 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 (~> 1.177.0) bcrypt (~> 3.1) + benchmark-ips bootsnap brakeman capybara @@ -531,6 +535,7 @@ DEPENDENCIES plaid propshaft puma (>= 5.0) + rack-mini-profiler rails (~> 7.2.2) rails-settings-cached redcarpet diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 79e30f25..e372a8d6 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -21,7 +21,7 @@ class PagesController < ApplicationController @accounts = Current.family.accounts.active @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency) - @transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological + @transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological # TODO: Placeholders for trendlines placeholder_series_data = 10.times.map do |i| diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 7478894b..e61feaaa 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -7,30 +7,36 @@ class TransactionsController < ApplicationController def index @q = search_params - search_query = Current.family.transactions.search(@q).reverse_chronological + search_query = Current.family.transactions.search(@q) set_focused_record(search_query, params[:focused_record_id], default_per_page: 50) @pagy, @transaction_entries = pagy( - search_query, + search_query.reverse_chronological.preload( + :account, + entryable: [ + :category, :merchant, :tags, + :transfer_as_inflow, + transfer_as_outflow: { + inflow_transaction: { entry: :account }, + outflow_transaction: { entry: :account } + } + ] + ), limit: params[:per_page].presence || default_params[:per_page], params: ->(params) { params.except(:focused_record_id) } ) - totals_query = search_query.incomes_and_expenses - family_currency = Current.family.currency - count_with_transfers = search_query.count - count_without_transfers = totals_query.count - - @totals = { - count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers, - income: totals_query.income_total(family_currency).abs, - expense: totals_query.expense_total(family_currency) - } + @transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact + @totals = search_query.stats(Current.family.currency) end def clear_filter - updated_params = stored_params.deep_dup + updated_params = { + "q" => search_params, + "page" => params[:page], + "per_page" => params[:per_page] + } q_params = updated_params["q"] || {} diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 7fb8a6a1..b3268fdd 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -8,10 +8,10 @@ module Account::EntriesHelper transfers.map(&:transfer).uniq end - def entries_by_date(entries, selectable: true, totals: false) - entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries| + def entries_by_date(entries, transfers: [], selectable: true, totals: false) + entries.group_by(&:date).map do |date, grouped_entries| content = capture do - yield grouped_entries + yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ] end next if content.blank? diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 9001c451..4a222014 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -1,6 +1,8 @@ class Account::Entry < ApplicationRecord include Monetizable + Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true) + monetize :amount belongs_to :account @@ -33,11 +35,11 @@ class Account::Entry < ApplicationRecord # All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses scope :incomes_and_expenses, -> { joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id") - .joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id") - .joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'") - .joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id") - .where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')") + .joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id") + .joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id") + .joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'") + .joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id") + .where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')") } scope :incomes, -> { @@ -154,20 +156,24 @@ class Account::Entry < ApplicationRecord all.size end - def income_total(currency = "USD", start_date: nil, end_date: nil) - total = incomes.where(date: start_date..end_date) - .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } - .sum + def stats(currency = "USD") + result = all + .incomes_and_expenses + .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ])) + .select( + "COUNT(*) AS count", + "SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total", + "SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total" + ) + .to_a + .first - Money.new(total, currency) - end - - def expense_total(currency = "USD", start_date: nil, end_date: nil) - total = expenses.where(date: start_date..end_date) - .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } - .sum - - Money.new(total, currency) + Stats.new( + currency: currency, + count: result.count, + income_total: result.income_total ? result.income_total * -1 : 0, + expense_total: result.expense_total || 0 + ) end end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index c008e623..c20bfa70 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,92 +1,115 @@ class Demo::Generator COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] - def initialize - @family = reset_family! - end + # Builds a semi-realistic mirror of what production data might look like + def reset_and_clear_data!(family_names) + puts "Clearing existing data..." - def reset_and_clear_data! - reset_settings! - clear_data! - create_user! + destroy_everything! - puts "user reset" - end + puts "Data cleared" - def reset_data! - Family.transaction do - reset_settings! - clear_data! - create_user! - - puts "user reset" - - create_tags! - create_categories! - create_merchants! - - puts "tags, categories, merchants created" - - create_credit_card_account! - create_checking_account! - create_savings_account! - - create_investment_account! - create_house_and_mortgage! - create_car_and_loan! - create_other_accounts! - - create_transfer_transactions! - - puts "accounts created" - puts "Demo data loaded successfully!" + family_names.each_with_index do |family_name, index| + create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") end + + puts "Users reset" + end + + def reset_data!(family_names) + puts "Clearing existing data..." + + destroy_everything! + + puts "Data cleared" + + family_names.each_with_index do |family_name, index| + create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", data_enrichment_enabled: index == 0) + end + + puts "Users reset" + + load_securities! + + puts "Securities loaded" + + family_names.each do |family_name| + family = Family.find_by(name: family_name) + + ActiveRecord::Base.transaction do + create_tags!(family) + create_categories!(family) + create_merchants!(family) + + puts "tags, categories, merchants created for #{family_name}" + + create_credit_card_account!(family) + create_checking_account!(family) + create_savings_account!(family) + + create_investment_account!(family) + create_house_and_mortgage!(family) + create_car_and_loan!(family) + create_other_accounts!(family) + + create_transfer_transactions!(family) + end + + puts "accounts created for #{family_name}" + end + + puts "Demo data loaded successfully!" end private - - attr_reader :family - - def reset_family! - family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id - - family = Family.find_by(id: family_id) - Transfer.destroy_all - family.destroy! if family - - Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload) - end - - def clear_data! - Transfer.destroy_all + def destroy_everything! + Family.destroy_all + Setting.destroy_all InviteCode.destroy_all - User.find_by_email("user@maybe.local")&.destroy ExchangeRate.destroy_all Security.destroy_all Security::Price.destroy_all end - def reset_settings! - Setting.destroy_all - end + def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false) + base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0" + id = Digest::UUID.uuid_v5(base_uuid, family_name) + + family = Family.create!( + id: id, + name: family_name, + stripe_subscription_status: "active", + data_enrichment_enabled: data_enrichment_enabled, + locale: "en", + country: "US", + timezone: "America/New_York", + date_format: "%m-%d-%Y" + ) - def create_user! family.users.create! \ - email: "user@maybe.local", + email: user_email, first_name: "Demo", last_name: "User", role: "admin", password: "password", onboarded_at: Time.current + + family.users.create! \ + email: "member_#{user_email}", + first_name: "Demo (member user)", + last_name: "User", + role: "member", + password: "password", + onboarded_at: Time.current end - def create_tags! + def create_tags!(family) [ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag| family.tags.create!(name: tag) end end - def create_categories! + def create_categories!(family) family.categories.bootstrap_defaults food = family.categories.find_by(name: "Food & Drink") @@ -95,7 +118,7 @@ class Demo::Generator family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense") end - def create_merchants! + def create_merchants!(family) merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco", "Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike", "Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ] @@ -105,25 +128,25 @@ class Demo::Generator end end - def create_credit_card_account! + def create_credit_card_account!(family) cc = family.accounts.create! \ accountable: CreditCard.new, name: "Chase Credit Card", balance: 2300, currency: "USD" - 50.times do - merchant = random_family_record(Merchant) + 800.times do + merchant = random_family_record(Merchant, family) create_transaction! \ account: cc, name: merchant.name, amount: Faker::Number.positive(to: 200), - tags: [ tag_for_merchant(merchant) ], - category: category_for_merchant(merchant), + tags: [ tag_for_merchant(merchant, family) ], + category: category_for_merchant(merchant, family), merchant: merchant end - 5.times do + 24.times do create_transaction! \ account: cc, amount: Faker::Number.negative(from: -1000), @@ -131,30 +154,30 @@ class Demo::Generator end end - def create_checking_account! + def create_checking_account!(family) checking = family.accounts.create! \ accountable: Depository.new, name: "Chase Checking", balance: 15000, currency: "USD" - 10.times do + 200.times do create_transaction! \ account: checking, name: "Expense", amount: Faker::Number.positive(from: 100, to: 1000) end - 10.times do + 50.times do create_transaction! \ account: checking, amount: Faker::Number.negative(from: -2000), name: "Income", - category: income_category + category: family.categories.find_by(name: "Income") end end - def create_savings_account! + def create_savings_account!(family) savings = family.accounts.create! \ accountable: Depository.new, name: "Demo Savings", @@ -162,20 +185,17 @@ class Demo::Generator currency: "USD", subtype: "savings" - income_category = categories.find { |c| c.name == "Income" } - income_tag = tags.find { |t| t.name == "Emergency Fund" } - - 20.times do + 100.times do create_transaction! \ account: savings, amount: Faker::Number.negative(from: -2000), - tags: [ income_tag ], - category: income_category, + tags: [ family.tags.find_by(name: "Emergency Fund") ], + category: family.categories.find_by(name: "Income"), name: "Income" end end - def create_transfer_transactions! + def create_transfer_transactions!(family) checking = family.accounts.find_by(name: "Chase Checking") credit_card = family.accounts.find_by(name: "Chase Credit Card") investment = family.accounts.find_by(name: "Robinhood") @@ -235,9 +255,7 @@ class Demo::Generator end end - def create_investment_account! - load_securities! - + def create_investment_account!(family) account = family.accounts.create! \ accountable: Investment.new, name: "Robinhood", @@ -275,7 +293,7 @@ class Demo::Generator end end - def create_house_and_mortgage! + def create_house_and_mortgage!(family) house = family.accounts.create! \ accountable: Property.new, name: "123 Maybe Way", @@ -293,7 +311,7 @@ class Demo::Generator currency: "USD" end - def create_car_and_loan! + def create_car_and_loan!(family) family.accounts.create! \ accountable: Vehicle.new, name: "Honda Accord", @@ -307,7 +325,7 @@ class Demo::Generator currency: "USD" end - def create_other_accounts! + def create_other_accounts!(family) family.accounts.create! \ accountable: OtherAsset.new, name: "Other Asset", @@ -326,7 +344,7 @@ class Demo::Generator transaction_attributes = attributes.slice(:category, :tags, :merchant) entry_defaults = { - date: Faker::Number.between(from: 0, to: 90).days.ago.to_date, + date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, currency: "USD", entryable: Account::Transaction.new(transaction_attributes) } @@ -344,12 +362,12 @@ class Demo::Generator entryable: Account::Valuation.new end - def random_family_record(model) + def random_family_record(model, family) family_records = model.where(family_id: family.id) model.offset(rand(family_records.count)).first end - def category_for_merchant(merchant) + def category_for_merchant(merchant, family) mapping = { "Amazon" => "Shopping", "Starbucks" => "Food & Drink", @@ -369,41 +387,20 @@ class Demo::Generator "Sephora" => "Shopping" } - categories.find { |c| c.name == mapping[merchant.name] } + family.categories.find_by(name: mapping[merchant.name]) end - def tag_for_merchant(merchant) + def tag_for_merchant(merchant, family) mapping = { "Delta Airlines" => "Trips", "Airbnb" => "Trips" } - tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] } - - tag_from_merchant || tags.find { |t| t.name == "Demo Tag" } + tag_from_merchant = family.tags.find_by(name: mapping[merchant.name]) + tag_from_merchant || family.tags.find_by(name: "Demo Tag") end def securities @securities ||= Security.all.to_a end - - def merchants - @merchants ||= family.merchants - end - - def categories - @categories ||= family.categories - end - - def tags - @tags ||= family.tags - end - - def income_tag - @income_tag ||= tags.find { |t| t.name == "Emergency Fund" } - end - - def income_category - @income_category ||= categories.find { |c| c.name == "Income" } - end end diff --git a/app/models/family.rb b/app/models/family.rb index 7ac41f25..329f8e3f 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -44,7 +44,12 @@ class Family < ApplicationRecord end def syncing? - super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?) + Sync.where( + "(syncable_type = 'Family' AND syncable_id = ?) OR + (syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR + (syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))", + id, id, id + ).where(status: [ "pending", "syncing" ]).exists? end def eu? diff --git a/app/models/user.rb b/app/models/user.rb index 92171040..cbafdb1c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,7 +17,8 @@ class User < ApplicationRecord enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true has_one_attached :profile_image do |attachable| - attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ] + attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 } + attachable.variant :small, resize_to_fill: [ 36, 36 ], convert: :webp, saver: { quality: 80 } end validate :profile_image_size diff --git a/app/views/account/trades/index.html.erb b/app/views/account/trades/index.html.erb index c33c1f21..c538a87c 100644 --- a/app/views/account/trades/index.html.erb +++ b/app/views/account/trades/index.html.erb @@ -32,7 +32,7 @@

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

<% else %>
- <%= entries_by_date(@entries) do |entries| %> + <%= entries_by_date(@entries) do |entries, _transfers| %> <%= render partial: "account/trades/trade", collection: entries, as: :entry %> <% end %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index fcfdb29c..3b679db0 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,19 +1,18 @@ <%# locals: (entry:, selectable: true, balance_trend: nil) %> -<% transaction, account = entry.account_transaction, entry.account %>
">
"> <% if selectable %> <%= check_box_tag dom_id(entry, "selection"), - disabled: entry.account_transaction.transfer?, + disabled: entry.entryable.transfer?, class: "maybe-checkbox maybe-checkbox--light", data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> <% end %>
<%= content_tag :div, class: ["flex items-center gap-2"] do %> - <% if transaction.merchant&.icon_url %> - <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> + <% if entry.entryable.merchant&.icon_url %> + <%= image_tag entry.entryable.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %> <% else %> <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> <% end %> @@ -24,8 +23,8 @@ <% if entry.new_record? %> <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name, - entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry), + <%= link_to entry.entryable.transfer? ? entry.entryable.transfer.name : entry.display_name, + entry.entryable.transfer? ? transfer_path(entry.entryable.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -36,14 +35,14 @@ <% end %> - <% if entry.account_transaction.transfer? %> + <% if entry.entryable.transfer? %> <%= render "account/transactions/transfer_match", entry: entry %> <% end %>
- <% if entry.account_transaction.transfer? %> - <%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %> + <% if entry.entryable.transfer? %> + <%= render "transfers/account_links", transfer: entry.entryable.transfer, is_inflow: entry.entryable.transfer_as_inflow.present? %> <% else %> <%= link_to entry.account.name, account_path(entry.account, tab: "transactions", focused_record_id: entry.id), data: { turbo_frame: "_top" }, class: "hover:underline" %> <% end %> diff --git a/app/views/account/transactions/index.html.erb b/app/views/account/transactions/index.html.erb index f30fcfd6..7855d1f6 100644 --- a/app/views/account/transactions/index.html.erb +++ b/app/views/account/transactions/index.html.erb @@ -19,7 +19,7 @@

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

<% else %>
- <%= entries_by_date(@entries) do |entries| %> + <%= entries_by_date(@entries) do |entries, _transfers| %> <%= render entries %> <% end %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 0b0657ed..e47e84fc 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -77,7 +77,7 @@
<% calculator = Account::BalanceTrendCalculator.for(@entries) %> - <%= entries_by_date(@entries) do |entries| %> + <%= entries_by_date(@entries) do |entries, _transfers| %> <% entries.each do |entry| %> <%= render entry, balance_trend: calculator&.trend_for(entry) %> <% end %> diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 42ac543d..005c4f36 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -5,13 +5,13 @@