From 8ba04b03305e2d1d4a1fc82cc97d63ea037289da Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 11 Nov 2024 09:21:13 -0500 Subject: [PATCH 001/626] Fix confirm message --- app/javascript/controllers/application.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index 6004862f..4b55996d 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -37,7 +37,17 @@ Turbo.setConfirmMethod((message) => { dialog.addEventListener( "close", () => { - resolve(dialog.returnValue === "confirm"); + const confirmed = dialog.returnValue === "confirm"; + + if (!confirmed) { + document.getElementById("turbo-confirm-title").innerHTML = + "Are you sure?"; + document.getElementById("turbo-confirm-body").innerHTML = + "You will not be able to undo this decision"; + document.getElementById("turbo-confirm-accept").innerHTML = "Confirm"; + } + + resolve(confirmed); }, { once: true }, ); From 3ef67faf7e833213422536a6b21362d25ea72564 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 11 Nov 2024 09:26:19 -0500 Subject: [PATCH 002/626] Show search bar even when no results in entries Fixes #1449 --- app/views/account/entries/index.html.erb | 27 ++++++++++----------- config/locales/views/account/entries/en.yml | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 30e02686..19df9200 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -21,31 +21,30 @@ - <% if @entries.empty? %> -

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

- <% else %> - -
- <%= form_with url: account_entries_path(@account), +
+ <%= form_with url: account_entries_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") %> - <%= form.search_field :search, +
+
+
+ <%= lucide_icon("search", class: "w-5 h-5 text-gray-500") %> + <%= 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 %> -
+
+ <% end %> +
+ <% if @entries.empty? %> +

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

+ <% else %> <%= tag.div id: dom_id(@account, "entries_bulk_select"), data: { controller: "bulk-select", diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index b4b4cb93..e117e90d 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -16,7 +16,7 @@ en: new: New new_balance: New balance new_transaction: New transaction - no_entries: No entries yet + no_entries: No entries found title: Activity loading: loading: Loading entries... From 31ecd3ccd4e1b7d1c4d2e63c9507579a101634a5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 11 Nov 2024 09:39:32 -0500 Subject: [PATCH 003/626] Fix precision in money input --- app/views/shared/_money_field.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index e0922e02..0bbbc0a7 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -26,7 +26,8 @@ value: if options[:value] sprintf("%.#{currency.default_precision}f", options[:value]) elsif form.object && form.object.respond_to?(amount_method) - form.object.public_send(amount_method) + val = form.object.public_send(amount_method) + sprintf("%.#{currency.default_precision}f", val) if val.present? end, min: options[:min] || -99999999999999, max: options[:max] || 99999999999999, From 278d04a73ae44a8791e0da800d226633351ac96e Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Mon, 11 Nov 2024 15:41:17 +0100 Subject: [PATCH 004/626] Fix registration fails silently when there are errors (#1455) * Fix registration fails silently with long passwords * Add maxlength --- app/controllers/registrations_controller.rb | 2 +- app/views/registrations/new.html.erb | 9 ++++++++- config/locales/views/registrations/en.yml | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index b5613d44..128b309c 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -28,7 +28,7 @@ class RegistrationsController < ApplicationController @session = create_session_for(@user) redirect_to root_path, notice: t(".success") else - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_entity, alert: t(".failure") end end diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 1c0f3fe7..855888a7 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -17,6 +17,13 @@
<% end %> +<% if @user.errors.present? %> +
+ <%= lucide_icon "circle-alert", class: "w-5 h-5" %> +

<%= @user.errors.full_messages.to_sentence %>

+
+<% end %> + <%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %> <%= form.email_field :email, autofocus: false, @@ -25,7 +32,7 @@ placeholder: "you@example.com", label: true, disabled: @invitation.present? %> - <%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %> + <%= form.password_field :password, autocomplete: "new-password", required: "required", label: true, maxlength: 72 %> <%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %> <% if invite_code_required? && !@invitation %> <%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %> diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 7468f909..cf4b77f2 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -11,6 +11,7 @@ en: create: invalid_invite_code: Invalid invite code, please try again. success: You have signed up successfully. + failure: There was a problem signing up. new: invitation_message: "%{inviter} has invited you to join as a %{role}" join_family_title: Join %{family} From fc3695dda9d1fecf3f6271c856fe5e76585f0bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:43:51 -0500 Subject: [PATCH 005/626] Bump ruby-lsp-rails from 0.3.21 to 0.3.26 (#1451) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.21 to 0.3.26. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.21...v0.3.26) --- 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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9330ae43..b7849ffd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -381,13 +381,13 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.20.1) + ruby-lsp (0.21.3) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.21) - ruby-lsp (>= 0.20.0, < 0.21.0) + ruby-lsp-rails (0.3.26) + ruby-lsp (>= 0.21.2, < 0.22.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) ffi (~> 1.12) @@ -417,7 +417,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11618) + sorbet-runtime (0.5.11645) stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) From 3d7a74862d1cccc4487b95500a02ae3adb5072ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:44:04 -0500 Subject: [PATCH 006/626] Bump aws-sdk-s3 from 1.169.0 to 1.170.0 (#1452) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.169.0 to 1.170.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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b7849ffd..6eedbbc4 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.992.0) - aws-sdk-core (3.210.0) + aws-partitions (1.1003.0) + aws-sdk-core (3.212.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -92,11 +92,11 @@ GEM aws-sdk-kms (1.95.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) + aws-sdk-s3 (1.170.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) From ed87023c0fb10dc55aa860351973b680b8bc8dcb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:44:18 -0500 Subject: [PATCH 007/626] Bump pagy from 9.1.1 to 9.2.1 (#1453) Bumps [pagy](https://github.com/ddnexus/pagy) from 9.1.1 to 9.2.1. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/9.1.1...9.2.1) --- updated-dependencies: - dependency-name: pagy 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 6eedbbc4..9f9fa15d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,7 +278,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.1.1) + pagy (9.2.1) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) From 9522a191de1f258d76ed996ba1c84e3a0629dd80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:57:30 -0500 Subject: [PATCH 008/626] Bump stripe from 13.1.0 to 13.1.1 (#1450) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.1.0 to 13.1.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.1.0...v13.1.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 9f9fa15d..8b61eab0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -422,7 +422,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.1) - stripe (13.1.0) + stripe (13.1.1) tailwindcss-rails (3.0.0) railties (>= 7.0.0) tailwindcss-ruby From 3bc9da4105d9463d8a76020a9e0d916c87b422ee Mon Sep 17 00:00:00 2001 From: Sergio Behrends Date: Mon, 11 Nov 2024 15:57:50 +0100 Subject: [PATCH 009/626] Adds a common DE format (#1445) --- app/helpers/application_helper.rb | 1 + app/views/import/configurations/_trade_import.html.erb | 2 +- app/views/import/configurations/_transaction_import.html.erb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f1194764..59ddebf9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -4,6 +4,7 @@ module ApplicationHelper def date_format_options [ [ "DD-MM-YYYY", "%d-%m-%Y" ], + [ "DD.MM.YY", "%d.%m.%Y" ], [ "MM-DD-YYYY", "%m-%d-%Y" ], [ "YYYY-MM-DD", "%Y-%m-%d" ], [ "DD/MM/YYYY", "%d/%m/%Y" ], diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index a953dc31..b60eb61b 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -3,7 +3,7 @@ <%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %> - <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %> + <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["DD.MM.YY", "%d.%m.%y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index 5bc48f75..c8bc103b 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -3,7 +3,7 @@ <%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %> - <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %> + <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["DD.MM.YY", "%d.%m.%y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
From cbba2ba6757feae6a22a3c9c0377457e5a761b66 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 13:49:37 -0500 Subject: [PATCH 010/626] Basic Plaid Integration (#1433) * Basic plaid data model and linking * Remove institutions, add plaid items * Improve schema and Plaid provider * Add webhook verification sketch * Webhook verification * Item accounts and balances sync setup * Provide test encryption keys * Fix test * Only provide encryption keys in prod * Try defining keys in test env * Consolidate account sync logic * Add back plaid account initialization * Plaid transaction sync * Sync UI overhaul for Plaid * Add liability and investment syncing * Handle investment webhooks and process current day holdings * Remove logs * Remove "all" period select for performance * fix amount calc * Remove todo comment * Coming soon for investment historical data * Document Plaid configuration * Listen for holding updates --- .env.example | 10 +- Gemfile | 2 + Gemfile.lock | 7 + app/assets/images/placeholder-graph.svg | 10 + .../stylesheets/application.tailwind.css | 4 +- app/controllers/accounts_controller.rb | 13 +- .../concerns/accountable_resource.rb | 29 ++- app/controllers/concerns/auto_sync.rb | 14 +- app/controllers/institutions_controller.rb | 40 ---- app/controllers/plaid_items_controller.rb | 38 +++ app/controllers/properties_controller.rb | 3 +- app/controllers/webhooks_controller.rb | 14 +- app/helpers/forms_helper.rb | 2 +- app/helpers/institutions_helper.rb | 5 - .../controllers/plaid_controller.js | 52 +++++ app/jobs/account_sync_job.rb | 7 - app/jobs/destroy_job.rb | 7 + app/jobs/sync_job.rb | 7 + app/models/account.rb | 20 +- app/models/account/balance/loader.rb | 7 +- app/models/account/entry.rb | 2 +- app/models/account/holding/syncer.rb | 3 +- app/models/account/sync.rb | 82 ------- app/models/account/syncable.rb | 29 --- app/models/account/valuation.rb | 2 +- app/models/concerns/plaidable.rb | 14 ++ app/models/concerns/syncable.rb | 33 +++ app/models/demo/generator.rb | 12 +- app/models/family.rb | 47 ++-- app/models/import/account_mapping.rb | 2 +- app/models/institution.rb | 25 -- app/models/plaid_account.rb | 208 +++++++++++++++++ app/models/plaid_item.rb | 127 ++++++++++ app/models/provider/plaid.rb | 220 ++++++++++++++++++ app/models/provider/plaid_sandbox.rb | 28 +++ app/models/sync.rb | 39 ++++ app/views/account/entries/index.html.erb | 32 +-- app/views/account/holdings/index.html.erb | 5 - app/views/account/trades/_trade.html.erb | 20 +- .../transactions/_transaction.html.erb | 6 +- app/views/account/transfers/_form.html.erb | 4 +- app/views/accounts/_account_type.html.erb | 2 +- app/views/accounts/_form.html.erb | 6 - app/views/accounts/_sync_all_button.html.erb | 4 - app/views/accounts/index.html.erb | 27 +-- .../_account_groups.erb} | 2 +- .../index/_institution_accounts.html.erb | 91 -------- ...nts.html.erb => _manual_accounts.html.erb} | 2 +- .../accounts/new/_method_selector.html.erb | 16 +- app/views/accounts/show/_header.html.erb | 6 +- app/views/accounts/show/_menu.html.erb | 30 ++- app/views/credit_cards/new.html.erb | 2 +- app/views/cryptos/new.html.erb | 2 +- app/views/depositories/new.html.erb | 2 +- app/views/institutions/_form.html.erb | 26 --- app/views/institutions/edit.html.erb | 3 - app/views/institutions/new.html.erb | 3 - app/views/investments/_chart.html.erb | 24 ++ app/views/investments/new.html.erb | 2 +- app/views/investments/show.html.erb | 6 +- app/views/layouts/application.html.erb | 9 +- app/views/loans/new.html.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 76 ++++++ app/views/shared/_notification.html.erb | 2 +- app/views/transactions/_form.html.erb | 2 +- config/credentials.yml.enc | 2 +- config/environments/production.rb | 2 + config/environments/test.rb | 5 + config/i18n-tasks.yml | 2 - config/initializers/plaid.rb | 10 + config/locales/models/account/sync/en.yml | 5 - config/locales/views/account/entries/en.yml | 2 +- config/locales/views/account/holdings/en.yml | 2 - config/locales/views/account/trades/en.yml | 7 - config/locales/views/accounts/en.yml | 33 +-- config/locales/views/credit_cards/en.yml | 6 - config/locales/views/cryptos/en.yml | 6 - config/locales/views/depositories/en.yml | 6 - config/locales/views/institutions/en.yml | 17 -- config/locales/views/investments/en.yml | 8 +- config/locales/views/layout/en.yml | 2 + config/locales/views/loans/en.yml | 6 - config/locales/views/other_assets/en.yml | 6 - config/locales/views/other_liabilities/en.yml | 6 - config/locales/views/plaid_items/en.yml | 20 ++ config/locales/views/properties/en.yml | 6 - config/locales/views/registrations/en.yml | 2 +- config/locales/views/vehicles/en.yml | 6 - config/routes.rb | 13 +- db/migrate/20241106193743_add_plaid_domain.rb | 56 +++++ ...241114164118_add_products_to_plaid_item.rb | 10 + db/schema.rb | 82 ++++--- test/application_system_test_case.rb | 3 - .../account/entries_controller_test.rb | 4 +- .../account/trades_controller_test.rb | 4 +- .../account/transactions_controller_test.rb | 2 +- .../account/transfers_controller_test.rb | 2 +- .../account/valuations_controller_test.rb | 2 +- test/controllers/accounts_controller_test.rb | 11 +- .../credit_cards_controller_test.rb | 4 +- .../institutions_controller_test.rb | 62 ----- ..._rate_provider_missings_controller_test.rb | 2 +- test/controllers/loans_controller_test.rb | 4 +- .../plaid_items_controller_test.rb | 49 ++++ .../controllers/properties_controller_test.rb | 4 +- .../transactions_controller_test.rb | 2 +- test/controllers/vehicles_controller_test.rb | 4 +- test/fixtures/account/syncs.yml | 12 - test/fixtures/accounts.yml | 11 +- test/fixtures/depositories.yml | 3 +- test/fixtures/families.yml | 2 + test/fixtures/institutions.yml | 8 - test/fixtures/plaid_accounts.yml | 3 + test/fixtures/plaid_items.yml | 5 + test/fixtures/syncs.yml | 17 ++ .../accountable_resource_interface_test.rb | 15 +- test/interfaces/syncable_interface_test.rb | 24 ++ test/jobs/account_balance_sync_job_test.rb | 7 - test/jobs/convert_currency_job_test.rb | 7 - test/jobs/daily_exchange_rate_job_test.rb | 7 - test/jobs/sync_job_test.rb | 13 ++ test/models/account/sync_test.rb | 48 ---- test/models/account_test.rb | 27 +-- test/models/family_test.rb | 45 ++-- test/models/plaid_item_test.rb | 21 ++ test/models/sync_test.rb | 34 +++ test/system/accounts_test.rb | 4 +- 127 files changed, 1537 insertions(+), 841 deletions(-) create mode 100644 app/assets/images/placeholder-graph.svg delete mode 100644 app/controllers/institutions_controller.rb create mode 100644 app/controllers/plaid_items_controller.rb delete mode 100644 app/helpers/institutions_helper.rb create mode 100644 app/javascript/controllers/plaid_controller.js delete mode 100644 app/jobs/account_sync_job.rb create mode 100644 app/jobs/destroy_job.rb create mode 100644 app/jobs/sync_job.rb delete mode 100644 app/models/account/sync.rb delete mode 100644 app/models/account/syncable.rb create mode 100644 app/models/concerns/plaidable.rb create mode 100644 app/models/concerns/syncable.rb delete mode 100644 app/models/institution.rb create mode 100644 app/models/plaid_account.rb create mode 100644 app/models/plaid_item.rb create mode 100644 app/models/provider/plaid.rb create mode 100644 app/models/provider/plaid_sandbox.rb create mode 100644 app/models/sync.rb delete mode 100644 app/views/accounts/_sync_all_button.html.erb rename app/views/accounts/{_accountable_group.html.erb => index/_account_groups.erb} (85%) delete mode 100644 app/views/accounts/index/_institution_accounts.html.erb rename app/views/accounts/index/{_institutionless_accounts.html.erb => _manual_accounts.html.erb} (89%) delete mode 100644 app/views/institutions/_form.html.erb delete mode 100644 app/views/institutions/edit.html.erb delete mode 100644 app/views/institutions/new.html.erb create mode 100644 app/views/plaid_items/_plaid_item.html.erb create mode 100644 config/initializers/plaid.rb delete mode 100644 config/locales/models/account/sync/en.yml delete mode 100644 config/locales/views/institutions/en.yml create mode 100644 config/locales/views/plaid_items/en.yml create mode 100644 db/migrate/20241106193743_add_plaid_domain.rb create mode 100644 db/migrate/20241114164118_add_products_to_plaid_item.rb delete mode 100644 test/controllers/institutions_controller_test.rb create mode 100644 test/controllers/plaid_items_controller_test.rb delete mode 100644 test/fixtures/account/syncs.yml delete mode 100644 test/fixtures/institutions.yml create mode 100644 test/fixtures/plaid_accounts.yml create mode 100644 test/fixtures/plaid_items.yml create mode 100644 test/fixtures/syncs.yml create mode 100644 test/interfaces/syncable_interface_test.rb delete mode 100644 test/jobs/account_balance_sync_job_test.rb delete mode 100644 test/jobs/convert_currency_job_test.rb delete mode 100644 test/jobs/daily_exchange_rate_job_test.rb create mode 100644 test/jobs/sync_job_test.rb delete mode 100644 test/models/account/sync_test.rb create mode 100644 test/models/plaid_item_test.rb create mode 100644 test/models/sync_test.rb diff --git a/.env.example b/.env.example index 1169e684..ef90f4a3 100644 --- a/.env.example +++ b/.env.example @@ -110,4 +110,12 @@ GITHUB_REPO_BRANCH=main # STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= \ No newline at end of file +STRIPE_WEBHOOK_SECRET= + +# ====================================================================================================== +# Plaid Configuration +# ====================================================================================================== +# +PLAID_CLIENT_ID= +PLAID_SECRET= +PLAID_ENV= \ No newline at end of file diff --git a/Gemfile b/Gemfile index 767c88d7..093377c5 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem "image_processing", ">= 1.2" # Other gem "bcrypt", "~> 3.1" +gem "jwt" gem "faraday" gem "faraday-retry" gem "faraday-multipart" @@ -50,6 +51,7 @@ gem "redcarpet" gem "stripe" gem "intercom-rails" gem "holidays" +gem "plaid" group :development, :test do gem "debug", platforms: %i[mri windows] diff --git a/Gemfile.lock b/Gemfile.lock index 8b61eab0..ac24d234 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,6 +224,8 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.7.2) + jwt (2.9.3) + base64 language_server-protocol (3.17.0.3) launchy (3.0.1) addressable (~> 2.8) @@ -284,6 +286,9 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) + plaid (33.0.0) + faraday (>= 1.0.1, < 3.0) + faraday-multipart (>= 1.0.1, < 2.0) prism (1.2.0) propshaft (1.1.0) actionpack (>= 7.0.0) @@ -495,12 +500,14 @@ DEPENDENCIES importmap-rails inline_svg intercom-rails + jwt letter_opener lucide-rails! mocha octokit pagy pg (~> 1.5) + plaid propshaft puma (>= 5.0) rails (~> 7.2.2) diff --git a/app/assets/images/placeholder-graph.svg b/app/assets/images/placeholder-graph.svg new file mode 100644 index 00000000..e868dbff --- /dev/null +++ b/app/assets/images/placeholder-graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 31a3d01f..6d989d27 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -101,7 +101,7 @@ } .btn { - @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500; + @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; } .btn--primary { @@ -113,7 +113,7 @@ } .btn--outline { - @apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50; + @apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400; } .btn--ghost { diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index cccef2d3..93a47388 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 - @institutions = Current.family.institutions - @accounts = Current.family.accounts.ungrouped.alphabetically + @manual_accounts = Current.family.accounts.manual.active.alphabetically + @plaid_items = Current.family.plaid_items.active.ordered end def summary @@ -27,11 +27,16 @@ class AccountsController < ApplicationController unless @account.syncing? @account.sync_later end + + redirect_to account_path(@account) end def sync_all - Current.family.accounts.active.sync - redirect_back_or_to accounts_path, notice: t(".success") + unless Current.family.syncing? + Current.family.sync_later + end + + redirect_to accounts_path end private diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 29ab519d..8f6a3244 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -4,6 +4,7 @@ module AccountableResource included do layout :with_sidebar before_action :set_account, only: [ :show, :edit, :update, :destroy ] + before_action :set_link_token, only: :new end class_methods do @@ -16,8 +17,7 @@ module AccountableResource def new @account = Current.family.accounts.build( currency: Current.family.currency, - accountable: accountable_type.new, - institution_id: params[:institution_id] + accountable: accountable_type.new ) end @@ -29,20 +29,35 @@ module AccountableResource def create @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) - redirect_to account_params[:return_to].presence || @account, notice: t(".success") + redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end def update @account.update_with_sync!(account_params.except(:return_to)) - redirect_back_or_to @account, notice: t(".success") + redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize) end def destroy - @account.destroy! - redirect_to accounts_path, notice: t(".success") + @account.destroy_later + redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize) end private + def set_link_token + @link_token = Current.family.get_link_token( + webhooks_url: webhooks_url, + redirect_url: accounts_url, + accountable_type: accountable_type.name + ) + end + + def webhooks_url + return webhooks_plaid_url if Rails.env.production? + + base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + base_url + "/webhooks/plaid" + end + def accountable_type controller_name.classify.constantize end @@ -53,7 +68,7 @@ module AccountableResource def account_params params.require(:account).permit( - :name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to, + :name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to, accountable_attributes: self.class.permitted_accountable_attributes ) end diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 122710a6..fa279034 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -2,12 +2,20 @@ module AutoSync extend ActiveSupport::Concern included do - before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? } + before_action :sync_family, if: :family_needs_auto_sync? end private - def sync_family - Current.family.sync + Current.family.update!(last_synced_at: Time.current) + Current.family.sync_later + end + + def family_needs_auto_sync? + return false unless Current.family.present? + return false unless Current.family.accounts.any? + + Current.family.last_synced_at.blank? || + Current.family.last_synced_at.to_date < Date.current end end diff --git a/app/controllers/institutions_controller.rb b/app/controllers/institutions_controller.rb deleted file mode 100644 index ba5f719d..00000000 --- a/app/controllers/institutions_controller.rb +++ /dev/null @@ -1,40 +0,0 @@ -class InstitutionsController < ApplicationController - before_action :set_institution, except: %i[new create] - - def new - @institution = Institution.new - end - - def create - Current.family.institutions.create!(institution_params) - redirect_to accounts_path, notice: t(".success") - end - - def edit - end - - def update - @institution.update!(institution_params) - redirect_to accounts_path, notice: t(".success") - end - - def destroy - @institution.destroy! - redirect_to accounts_path, notice: t(".success") - end - - def sync - @institution.sync - redirect_back_or_to accounts_path, notice: t(".success") - end - - private - - def institution_params - params.require(:institution).permit(:name, :logo) - end - - def set_institution - @institution = Current.family.institutions.find(params[:id]) - end -end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb new file mode 100644 index 00000000..c0ac89ad --- /dev/null +++ b/app/controllers/plaid_items_controller.rb @@ -0,0 +1,38 @@ +class PlaidItemsController < ApplicationController + before_action :set_plaid_item, only: %i[destroy sync] + + def create + Current.family.plaid_items.create_from_public_token( + plaid_item_params[:public_token], + item_name: item_name, + ) + + redirect_to accounts_path, notice: t(".success") + end + + def destroy + @plaid_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @plaid_item.syncing? + @plaid_item.sync_later + end + + redirect_to accounts_path + end + + private + def set_plaid_item + @plaid_item = Current.family.plaid_items.find(params[:id]) + end + + def plaid_item_params + params.require(:plaid_item).permit(:public_token, metadata: {}) + end + + def item_name + plaid_item_params.dig(:metadata, :institution, :name) + end +end diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index d1cd21cf..fb6048e9 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -11,8 +11,7 @@ class PropertiesController < ApplicationController currency: Current.family.currency, accountable: Property.new( address: Address.new - ), - institution_id: params[:institution_id] + ) ) end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 23e431f7..56ce7c98 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,7 +1,19 @@ class WebhooksController < ApplicationController - skip_before_action :verify_authenticity_token, only: [ :stripe ] + skip_before_action :verify_authenticity_token skip_authentication + def plaid + webhook_body = request.body.read + plaid_verification_header = request.headers["Plaid-Verification"] + + Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body) + Provider::Plaid.process_webhook(webhook_body) + + render json: { received: true }, status: :ok + rescue => error + render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request + end + def stripe webhook_body = request.body.read sig_header = request.env["HTTP_STRIPE_SIGNATURE"] diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 49f303cb..2099770c 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -18,7 +18,7 @@ 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" ], [ "All", "all" ] ] + periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ] form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" }) end diff --git a/app/helpers/institutions_helper.rb b/app/helpers/institutions_helper.rb deleted file mode 100644 index 3fa70e37..00000000 --- a/app/helpers/institutions_helper.rb +++ /dev/null @@ -1,5 +0,0 @@ -module InstitutionsHelper - def institution_logo(institution) - institution.logo.attached? ? institution.logo : institution.logo_url - end -end diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js new file mode 100644 index 00000000..a36f40af --- /dev/null +++ b/app/javascript/controllers/plaid_controller.js @@ -0,0 +1,52 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="plaid" +export default class extends Controller { + static values = { + linkToken: String, + }; + + open() { + const handler = Plaid.create({ + token: this.linkTokenValue, + onSuccess: this.handleSuccess, + onLoad: this.handleLoad, + onExit: this.handleExit, + onEvent: this.handleEvent, + }); + + handler.open(); + } + + handleSuccess(public_token, metadata) { + fetch("/plaid_items", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, + }, + body: JSON.stringify({ + plaid_item: { + public_token: public_token, + metadata: metadata, + }, + }), + }).then((response) => { + if (response.redirected) { + window.location.href = response.url; + } + }); + } + + handleExit(err, metadata) { + // no-op + } + + handleEvent(eventName, metadata) { + // no-op + } + + handleLoad() { + // no-op + } +} diff --git a/app/jobs/account_sync_job.rb b/app/jobs/account_sync_job.rb deleted file mode 100644 index cdcefffc..00000000 --- a/app/jobs/account_sync_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AccountSyncJob < ApplicationJob - queue_as :default - - def perform(account, start_date: nil) - account.sync(start_date: start_date) - end -end diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb new file mode 100644 index 00000000..2296c45f --- /dev/null +++ b/app/jobs/destroy_job.rb @@ -0,0 +1,7 @@ +class DestroyJob < ApplicationJob + queue_as :default + + def perform(model) + model.destroy + end +end diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb new file mode 100644 index 00000000..c6f06253 --- /dev/null +++ b/app/jobs/sync_job.rb @@ -0,0 +1,7 @@ +class SyncJob < ApplicationJob + queue_as :default + + def perform(sync) + sync.perform + end +end diff --git a/app/models/account.rb b/app/models/account.rb index bc4f7123..fcc9dc25 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -4,8 +4,8 @@ class Account < ApplicationRecord validates :name, :balance, :currency, presence: true belongs_to :family - belongs_to :institution, optional: true belongs_to :import, optional: true + belongs_to :plaid_account, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy, class_name: "Account::Entry" @@ -14,18 +14,17 @@ class Account < ApplicationRecord has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - has_many :syncs, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy monetize :balance enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } - scope :active, -> { where(is_active: true) } + scope :active, -> { where(is_active: true, scheduled_for_deletion: false) } scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } - scope :ungrouped, -> { where(institution_id: nil) } + scope :manual, -> { where(plaid_account_id: nil) } has_one_attached :logo @@ -87,6 +86,19 @@ class Account < ApplicationRecord end end + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + + resolve_stale_issues + Balance::Syncer.new(self, start_date: start_date).run + Holding::Syncer.new(self, start_date: start_date).run + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) diff --git a/app/models/account/balance/loader.rb b/app/models/account/balance/loader.rb index cb6ba0bd..56c02011 100644 --- a/app/models/account/balance/loader.rb +++ b/app/models/account/balance/loader.rb @@ -19,7 +19,12 @@ class Account::Balance::Loader def update_account_balance!(balances) last_balance = balances.select { |db| db.currency == account.currency }.last&.balance - account.update! balance: last_balance if last_balance.present? + + if account.plaid_account.present? + account.update! balance: account.plaid_account.current_balance || last_balance + else + account.update! balance: last_balance if last_balance.present? + end end def upsert_balances!(balances) diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b6692788..09d19618 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -87,7 +87,7 @@ class Account::Entry < ApplicationRecord class << self # arbitrary cutoff date to avoid expensive sync operations def min_supported_date - 20.years.ago.to_date + 30.years.ago.to_date end def daily_totals(entries, currency, period: Period.last_30_days) diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb index f3c7c1fe..de5462fa 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/account/holding/syncer.rb @@ -1,7 +1,8 @@ class Account::Holding::Syncer def initialize(account, start_date: nil) @account = account - @sync_date_range = calculate_sync_start_date(start_date)..Date.current + end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current + @sync_date_range = calculate_sync_start_date(start_date)..end_date @portfolio = {} load_prior_portfolio if start_date diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb deleted file mode 100644 index 28ae251a..00000000 --- a/app/models/account/sync.rb +++ /dev/null @@ -1,82 +0,0 @@ -class Account::Sync < ApplicationRecord - belongs_to :account - - enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } - - class << self - def for(account, start_date: nil) - create! account: account, start_date: start_date - end - - def latest - order(created_at: :desc).first - end - end - - def run - start! - - account.resolve_stale_issues - - sync_balances - sync_holdings - - complete! - rescue StandardError => error - account.observe_unknown_issue(error) - fail! error - - raise error if Rails.env.development? - end - - private - - def sync_balances - Account::Balance::Syncer.new(account, start_date: start_date).run - end - - def sync_holdings - Account::Holding::Syncer.new(account, start_date: start_date).run - end - - def start! - update! status: "syncing", last_ran_at: Time.now - broadcast_start - end - - def complete! - update! status: "completed" - - if account.has_issues? - broadcast_result type: "alert", message: account.highest_priority_issue.title - else - broadcast_result type: "notice", message: "Sync complete" - end - end - - def fail!(error) - update! status: "failed", error: error.message - broadcast_result type: "alert", message: I18n.t("account.sync.failed") - end - - def broadcast_start - broadcast_append_to( - [ account.family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { id: id, type: "processing", message: "Syncing account balances" } - ) - end - - def broadcast_result(type:, message:) - broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification - broadcast_append_to( - [ account.family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { type: type, message: message } - ) - - account.family.broadcast_refresh - end -end diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb deleted file mode 100644 index 382d401f..00000000 --- a/app/models/account/syncable.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Account::Syncable - extend ActiveSupport::Concern - - class_methods do - def sync(start_date: nil) - all.each { |a| a.sync_later(start_date: start_date) } - end - end - - def syncing? - syncs.syncing.any? - end - - def latest_sync_date - syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date - end - - def needs_sync? - latest_sync_date.nil? || latest_sync_date < Date.current - end - - def sync_later(start_date: nil) - AccountSyncJob.perform_later(self, start_date: start_date) - end - - def sync(start_date: nil) - Account::Sync.for(self, start_date: start_date).run - end -end diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index f5f5aa50..653c11e2 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -12,7 +12,7 @@ class Account::Valuation < ApplicationRecord end def name - oldest? ? "Initial balance" : entry.name || "Balance update" + entry.name || (oldest? ? "Initial balance" : "Balance update") end def trend diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb new file mode 100644 index 00000000..ddecd893 --- /dev/null +++ b/app/models/concerns/plaidable.rb @@ -0,0 +1,14 @@ +module Plaidable + extend ActiveSupport::Concern + + class_methods do + def plaid_provider + Provider::Plaid.new if Rails.application.config.plaid + end + end + + private + def plaid_provider + self.class.plaid_provider + end +end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb new file mode 100644 index 00000000..ec6abb2e --- /dev/null +++ b/app/models/concerns/syncable.rb @@ -0,0 +1,33 @@ +module Syncable + extend ActiveSupport::Concern + + included do + has_many :syncs, as: :syncable, dependent: :destroy + end + + def syncing? + syncs.where(status: [ :syncing, :pending ]).any? + end + + def sync_later(start_date: nil) + new_sync = syncs.create!(start_date: start_date) + SyncJob.perform_later(new_sync) + end + + def sync(start_date: nil) + syncs.create!(start_date: start_date).perform + end + + def sync_data(start_date: nil) + raise NotImplementedError, "Subclasses must implement the `sync_data` method" + end + + def sync_error + latest_sync.error + end + + private + def latest_sync + syncs.order(created_at: :desc).first + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 48a28d2e..29985a36 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -107,8 +107,7 @@ class Demo::Generator accountable: CreditCard.new, name: "Chase Credit Card", balance: 2300, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Chase") + currency: "USD" 50.times do merchant = random_family_record(Merchant) @@ -134,8 +133,7 @@ class Demo::Generator accountable: Depository.new, name: "Chase Checking", balance: 15000, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Chase") + currency: "USD" 10.times do create_transaction! \ @@ -159,8 +157,7 @@ class Demo::Generator name: "Demo Savings", balance: 40000, currency: "USD", - subtype: "savings", - institution: family.institutions.find_or_create_by(name: "Chase") + subtype: "savings" income_category = categories.find { |c| c.name == "Income" } income_tag = tags.find { |t| t.name == "Emergency Fund" } @@ -208,8 +205,7 @@ class Demo::Generator accountable: Investment.new, name: "Robinhood", balance: 100000, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Robinhood") + currency: "USD" aapl = Security.find_by(ticker: "AAPL") tm = Security.find_by(ticker: "TM") diff --git a/app/models/family.rb b/app/models/family.rb index 8d0d063b..0e9226f8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,6 @@ class Family < ApplicationRecord + include Plaidable, Syncable + DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] include Providable @@ -7,17 +9,46 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :institutions, dependent: :destroy has_many :imports, dependent: :destroy has_many :transactions, through: :accounts has_many :entries, through: :accounts has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy has_many :issues, through: :accounts + has_many :plaid_items, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } + def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + + accounts.manual.each do |account| + account.sync_data(start_date: start_date) + end + + plaid_items.each do |plaid_item| + plaid_item.sync_data(start_date: start_date) + end + end + + def syncing? + 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 + + plaid_provider.get_link_token( + user_id: id, + country: country, + language: locale, + webhooks_url: webhooks_url, + redirect_url: redirect_url, + accountable_type: accountable_type + ).link_token + end + def snapshot(period = Period.all) query = accounts.active.joins(:balances) .where("account_balances.currency = ?", self.currency) @@ -116,20 +147,6 @@ class Family < ApplicationRecord Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency) end - def sync(start_date: nil) - accounts.active.each do |account| - if account.needs_sync? - account.sync_later(start_date: start_date || account.last_sync_date) - end - end - - update! last_synced_at: Time.now - end - - def needs_sync? - last_synced_at.nil? || last_synced_at.to_date < Date.current - end - def synth_usage self.class.synth_provider&.usage end diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb index 67b11ba5..c4c00414 100644 --- a/app/models/import/account_mapping.rb +++ b/app/models/import/account_mapping.rb @@ -8,7 +8,7 @@ class Import::AccountMapping < Import::Mapping end def selectable_values - family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] } + family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] } unless key.blank? family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ] diff --git a/app/models/institution.rb b/app/models/institution.rb deleted file mode 100644 index d34ecd0e..00000000 --- a/app/models/institution.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Institution < ApplicationRecord - belongs_to :family - has_many :accounts, dependent: :nullify - has_one_attached :logo - - scope :alphabetically, -> { order(name: :asc) } - - def sync - accounts.active.each do |account| - if account.needs_sync? - account.sync - end - end - - update! last_synced_at: Time.now - end - - def syncing? - accounts.active.any? { |account| account.syncing? } - end - - def has_issues? - accounts.active.any? { |account| account.has_issues? } - end -end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb new file mode 100644 index 00000000..2c1d0a30 --- /dev/null +++ b/app/models/plaid_account.rb @@ -0,0 +1,208 @@ +class PlaidAccount < ApplicationRecord + include Plaidable + + TYPE_MAPPING = { + "depository" => Depository, + "credit" => CreditCard, + "loan" => Loan, + "investment" => Investment, + "other" => OtherAsset + } + + belongs_to :plaid_item + + has_one :account, dependent: :destroy + + accepts_nested_attributes_for :account + + class << self + def find_or_create_from_plaid_data!(plaid_data, family) + find_or_create_by!(plaid_id: plaid_data.account_id) do |a| + a.account = family.accounts.new( + name: plaid_data.name, + balance: plaid_data.balances.current, + currency: plaid_data.balances.iso_currency_code, + accountable: TYPE_MAPPING[plaid_data.type].new + ) + end + end + end + + def sync_account_data!(plaid_account_data) + update!( + current_balance: plaid_account_data.balances.current, + available_balance: plaid_account_data.balances.available, + currency: plaid_account_data.balances.iso_currency_code, + plaid_type: plaid_account_data.type, + plaid_subtype: plaid_account_data.subtype, + account_attributes: { + id: account.id, + balance: plaid_account_data.balances.current + } + ) + end + + def sync_investments!(transactions:, holdings:, securities:) + transactions.each do |transaction| + if transaction.type == "cash" + new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.amount + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) + t.entryable = Account::Transaction.new + end + else + security = get_security(transaction.security, securities) + next if security.nil? + new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.quantity * transaction.price + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.entryable = Account::Trade.new( + security: security, + qty: transaction.quantity, + price: transaction.price, + currency: transaction.iso_currency_code + ) + end + end + end + + # Update only the current day holdings. The account sync will populate historical values based on trades. + holdings.each do |holding| + internal_security = get_security(holding.security, securities) + next if internal_security.nil? + + existing_holding = account.holdings.find_or_initialize_by( + security: internal_security, + date: Date.current, + currency: holding.iso_currency_code + ) + + existing_holding.qty = holding.quantity + existing_holding.price = holding.institution_price + existing_holding.amount = holding.quantity * holding.institution_price + existing_holding.save! + end + end + + def sync_credit_data!(plaid_credit_data) + account.update!( + accountable_attributes: { + id: account.accountable_id, + minimum_payment: plaid_credit_data.minimum_payment_amount, + apr: plaid_credit_data.aprs.first&.apr_percentage + } + ) + end + + def sync_mortgage_data!(plaid_mortgage_data) + create_initial_loan_balance(plaid_mortgage_data) + + account.update!( + accountable_attributes: { + id: account.accountable_id, + rate_type: plaid_mortgage_data.interest_rate&.type, + interest_rate: plaid_mortgage_data.interest_rate&.percentage + } + ) + end + + def sync_student_loan_data!(plaid_student_loan_data) + create_initial_loan_balance(plaid_student_loan_data) + + account.update!( + accountable_attributes: { + id: account.accountable_id, + rate_type: "fixed", + interest_rate: plaid_student_loan_data.interest_rate_percentage + } + ) + end + + def sync_transactions!(added:, modified:, removed:) + added.each do |plaid_txn| + account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t| + t.name = plaid_txn.name + t.amount = plaid_txn.amount + t.currency = plaid_txn.iso_currency_code + t.date = plaid_txn.date + t.marked_as_transfer = transfer?(plaid_txn) + t.entryable = Account::Transaction.new( + category: get_category(plaid_txn.personal_finance_category.primary), + merchant: get_merchant(plaid_txn.merchant_name) + ) + end + end + + modified.each do |plaid_txn| + existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id) + + existing_txn.update!( + amount: plaid_txn.amount, + date: plaid_txn.date + ) + end + + removed.each do |plaid_txn| + account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy + end + end + + private + def family + plaid_item.family + end + + def get_security(plaid_security, securities) + security = nil + + if plaid_security.ticker_symbol.present? + security = plaid_security + else + security = securities.find { |s| s.security_id == plaid_security.proxy_security_id } + end + + Security.find_or_create_by!( + ticker: security.ticker_symbol, + exchange_mic: security.market_identifier_code || "XNAS", + country_code: "US" + ) if security.present? + end + + def transfer?(plaid_txn) + transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ] + + transfer_categories.include?(plaid_txn.personal_finance_category.primary) + end + + def create_initial_loan_balance(loan_data) + if loan_data.origination_principal_amount.present? && loan_data.origination_date.present? + account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e| + e.name = "Initial Principal" + e.amount = loan_data.origination_principal_amount + e.currency = account.currency + e.date = loan_data.origination_date + e.entryable = Account::Valuation.new + end + end + end + + # See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv + def get_category(plaid_category) + ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ] + + return nil if ignored_categories.include?(plaid_category) + + family.categories.find_or_create_by!(name: plaid_category.titleize) + end + + def get_merchant(plaid_merchant_name) + return nil if plaid_merchant_name.blank? + + family.merchants.find_or_create_by!(name: plaid_merchant_name) + end +end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb new file mode 100644 index 00000000..c2ca4cbc --- /dev/null +++ b/app/models/plaid_item.rb @@ -0,0 +1,127 @@ +class PlaidItem < ApplicationRecord + include Plaidable, Syncable + + encrypts :access_token, deterministic: true + validates :name, :access_token, presence: true + + before_destroy :remove_plaid_item + + belongs_to :family + has_one_attached :logo + + has_many :plaid_accounts, dependent: :destroy + has_many :accounts, through: :plaid_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + + class << self + def create_from_public_token(token, item_name:) + response = plaid_provider.exchange_public_token(token) + + new_plaid_item = create!( + name: item_name, + plaid_id: response.item_id, + access_token: response.access_token, + ) + + new_plaid_item.sync_later + end + end + + def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + + fetch_and_load_plaid_data + + accounts.each do |account| + account.sync_data(start_date: start_date) + end + end + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def has_investment_accounts? + available_products.include?("investments") || billed_products.include?("investments") + end + + def has_liability_accounts? + available_products.include?("liabilities") || billed_products.include?("liabilities") + end + + private + def fetch_and_load_plaid_data + item = plaid_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 + + internal_plaid_accounts = fetched_accounts.map do |account| + internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family) + internal_plaid_account.sync_account_data!(account) + internal_plaid_account + end + + fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) unless has_investment_accounts? + + if fetched_transactions + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } + modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id } + removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_transactions!(added:, modified:, removed:) + end + + update!(next_cursor: fetched_transactions.cursor) + end + end + + fetched_investments = safe_fetch_plaid_data(:get_item_investments) if has_investment_accounts? + + if fetched_investments + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } + holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id } + securities = fetched_investments.securities + + internal_plaid_account.sync_investments!(transactions:, holdings:, securities:) + end + end + end + + fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) if has_liability_accounts? + + if fetched_liabilities + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + credit = fetched_liabilities.credit.find { |l| l.account_id == internal_plaid_account.plaid_id } + mortgage = fetched_liabilities.mortgage.find { |l| l.account_id == internal_plaid_account.plaid_id } + student = fetched_liabilities.student.find { |l| l.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_credit_data!(credit) if credit + internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage + internal_plaid_account.sync_student_loan_data!(student) if student + end + end + end + end + + def safe_fetch_plaid_data(method) + begin + plaid_provider.send(method, self) + rescue Plaid::ApiError => e + Rails.logger.warn("Error fetching #{method} for item #{id}: #{e.message}") + nil + end + end + + def remove_plaid_item + plaid_provider.remove_item(access_token) + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb new file mode 100644 index 00000000..50c32f4e --- /dev/null +++ b/app/models/provider/plaid.rb @@ -0,0 +1,220 @@ +class Provider::Plaid + attr_reader :client + + PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze + PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze + PLAID_PRODUCTS = %w[transactions investments liabilities].freeze + MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 + + class << self + def process_webhook(webhook_body) + parsed = JSON.parse(webhook_body) + type = parsed["webhook_type"] + code = parsed["webhook_code"] + + item = PlaidItem.find_by(plaid_id: parsed["item_id"]) + + case [ type, code ] + when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] + item.sync_later + when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] + item.sync_later + when [ "HOLDINGS", "DEFAULT_UPDATE" ] + item.sync_later + else + Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") + end + end + + def validate_webhook!(verification_header, raw_body) + jwks_loader = ->(options) do + key_id = options[:kid] + + jwk_response = client.webhook_verification_key_get( + Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) + ) + + jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ]) + + jwks.filter! { |key| key[:use] == "sig" } + jwks + end + + payload, _header = JWT.decode( + verification_header, nil, true, + { + algorithms: [ "ES256" ], + jwks: jwks_loader, + verify_expiration: false + } + ) + + issued_at = Time.at(payload["iat"]) + raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes + + expected_hash = payload["request_body_sha256"] + 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 + end + + def get_link_token(user_id:, country:, language: "en", webhooks_url:, redirect_url:, accountable_type: nil) + request = Plaid::LinkTokenCreateRequest.new({ + user: { client_user_id: user_id }, + client_name: "Maybe Finance", + products: get_products(accountable_type), + country_codes: [ get_plaid_country_code(country) ], + language: get_plaid_language(language), + webhook: webhooks_url, + redirect_uri: redirect_url, + transactions: { days_requested: MAX_HISTORY_DAYS } + }) + + client.link_token_create(request) + end + + def exchange_public_token(token) + request = Plaid::ItemPublicTokenExchangeRequest.new( + public_token: token + ) + + client.item_public_token_exchange(request) + end + + def get_item(access_token) + request = Plaid::ItemGetRequest.new(access_token: access_token) + client.item_get(request) + end + + def remove_item(access_token) + request = Plaid::ItemRemoveRequest.new(access_token: access_token) + client.item_remove(request) + end + + def get_item_accounts(item) + request = Plaid::AccountsGetRequest.new(access_token: item.access_token) + client.accounts_get(request) + end + + def get_item_transactions(item) + cursor = item.next_cursor + added = [] + modified = [] + removed = [] + has_more = true + + while has_more + request = Plaid::TransactionsSyncRequest.new( + access_token: item.access_token, + cursor: cursor + ) + + response = client.transactions_sync(request) + + added += response.added + modified += response.modified + removed += response.removed + has_more = response.has_more + cursor = response.next_cursor + end + + TransactionSyncResponse.new(added:, modified:, removed:, cursor:) + end + + def get_item_investments(item, start_date: nil, end_date: Date.current) + start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date + holdings = get_item_holdings(item) + transactions, securities = get_item_investment_transactions(item, start_date:, end_date:) + + InvestmentsResponse.new(holdings:, transactions:, securities:) + end + + def get_item_liabilities(item) + request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token }) + response = client.liabilities_get(request) + response.liabilities + end + + private + TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true + InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true + + def get_item_holdings(item) + request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) + response = client.investments_holdings_get(request) + + securities_by_id = response.securities.index_by(&:security_id) + accounts_by_id = response.accounts.index_by(&:account_id) + + response.holdings.each do |holding| + holding.define_singleton_method(:security) { securities_by_id[holding.security_id] } + holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] } + end + + response.holdings + end + + def get_item_investment_transactions(item, start_date:, end_date:) + transactions = [] + securities = [] + offset = 0 + + loop do + request = Plaid::InvestmentsTransactionsGetRequest.new( + access_token: item.access_token, + start_date: start_date.to_s, + end_date: end_date.to_s, + options: { offset: offset } + ) + + response = client.investments_transactions_get(request) + securities_by_id = response.securities.index_by(&:security_id) + accounts_by_id = response.accounts.index_by(&:account_id) + + response.investment_transactions.each do |t| + t.define_singleton_method(:security) { securities_by_id[t.security_id] } + t.define_singleton_method(:account) { accounts_by_id[t.account_id] } + transactions << t + end + + securities += response.securities + + break if transactions.length >= response.total_investment_transactions + offset = transactions.length + end + + [ transactions, securities ] + end + + def get_products(accountable_type) + case accountable_type + when "Investment" + %w[investments] + when "CreditCard", "Loan" + %w[liabilities] + else + %w[transactions] + end + end + + def get_plaid_country_code(country_code) + PLAID_COUNTRY_CODES.include?(country_code) ? country_code : "US" + end + + def get_plaid_language(locale = "en") + language = locale.split("-").first + PLAID_LANGUAGES.include?(language) ? language : "en" + end +end diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb new file mode 100644 index 00000000..132b4422 --- /dev/null +++ b/app/models/provider/plaid_sandbox.rb @@ -0,0 +1,28 @@ +class Provider::PlaidSandbox < Provider::Plaid + attr_reader :client + + def initialize + @client = create_client + end + + def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE") + client.sandbox_item_fire_webhook( + Plaid::SandboxItemFireWebhookRequest.new( + access_token: item.access_token, + webhook_type: type, + webhook_code: code, + ) + ) + end + + private + def create_client + raise "Plaid sandbox is not supported in production" if Rails.env.production? + + api_client = Plaid::ApiClient.new( + Rails.application.config.plaid + ) + + Plaid::PlaidApi.new(api_client) + end +end diff --git a/app/models/sync.rb b/app/models/sync.rb new file mode 100644 index 00000000..c0a8b53c --- /dev/null +++ b/app/models/sync.rb @@ -0,0 +1,39 @@ +class Sync < ApplicationRecord + belongs_to :syncable, polymorphic: true + + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } + + scope :ordered, -> { order(created_at: :desc) } + + def perform + start! + + syncable.sync_data(start_date: start_date) + + complete! + rescue StandardError => error + fail! error + raise error if Rails.env.development? + end + + private + def family + syncable.is_a?(Family) ? syncable : syncable.family + end + + def start! + update! status: :syncing + end + + def complete! + update! status: :completed, last_ran_at: Time.current + + family.broadcast_refresh + end + + def fail!(error) + update! status: :failed, error: error.message, last_ran_at: Time.current + + family.broadcast_refresh + end +end diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 19df9200..e501a0cf 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -2,23 +2,25 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> -
- - + <% end %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index 993e6b15..af87088b 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -23,11 +23,6 @@
<% if @holdings.any? %> <%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %> - <% elsif @account.needs_sync? || true %> -
-

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

- <%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %> -
<% else %>

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

<% end %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index f7974079..29cd40f4 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -3,7 +3,7 @@ <% trade, account = entry.account_trade, entry.account %>
-
+
<% if selectable %> <%= check_box_tag dom_id(entry, "selection"), class: "maybe-checkbox maybe-checkbox--light", @@ -30,21 +30,11 @@
-
- <% if entry.account_transaction? && entry.marked_as_transfer? %> - <%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %> - <% elsif entry.account_transaction? %> - <%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %> - <% else %> - <%= tag.p trade.buy? ? t(".buy") : t(".sell") %> - <% end %> +
+ <%= tag.span format_money(entry.amount_money) %>
-
- <% if entry.account_transaction? %> - <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %> - <% else %> - <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %> - <% end %> +
+ <%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index ae288d56..ebdda70d 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -68,7 +68,11 @@ <% if show_balance %>
- <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <% if entry.account.investment? %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% else %> + <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <% end %>
<% end %>
diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 2d35cef0..0ce7566a 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -26,8 +26,8 @@
- <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> - <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> + <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> + <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.money_field :amount, label: t(".amount"), required: true, hide_currency: true %> <%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index a82915c4..ffcc90ae 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,6 +1,6 @@ <%# locals: (accountable:) %> -<%= link_to new_polymorphic_path(accountable, institution_id: params[:institution_id], step: "method_select", return_to: params[:return_to]), +<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-alpha-black-25 hover:bg-alpha-black-25 border border-transparent block px-2 rounded-lg p-2" do %> <%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %> diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 88e30bea..2910879b 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -5,12 +5,6 @@ <%= form.hidden_field :accountable_type %> <%= form.hidden_field :return_to, value: params[:return_to] %> - <% if account.new_record? %> - <%= form.hidden_field :institution_id %> - <% else %> - <%= form.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> - <% end %> - <%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %> <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> diff --git a/app/views/accounts/_sync_all_button.html.erb b/app/views/accounts/_sync_all_button.html.erb deleted file mode 100644 index 8300df19..00000000 --- a/app/views/accounts/_sync_all_button.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %> - <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> - <%= t(".sync") %> -<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 431fe4cd..a350e607 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -7,19 +7,14 @@

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

- <%= contextual_menu do %> -
- <%= link_to new_institution_path, - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal", - data: { turbo_frame: "modal" } do %> - <%= lucide_icon "building-2", class: "w-5 h-5 text-gray-500" %> - <%= t(".add_institution") %> - <% end %> -
+ <%= button_to sync_all_accounts_path, + disabled: Current.family.syncing?, + class: "btn btn--outline flex items-center gap-2", + title: t(".sync") do %> + <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> + <%= t(".sync") %> <% end %> - <%= render "sync_all_button" %> - <%= link_to new_account_path(return_to: accounts_path), data: { turbo_frame: "modal" }, class: "btn btn--primary flex items-center gap-1" do %> @@ -30,16 +25,16 @@
- <% if @accounts.empty? && @institutions.empty? %> + <% if @manual_accounts.empty? && @plaid_items.empty? %> <%= render "empty" %> <% else %>
- <% @institutions.each do |institution| %> - <%= render "accounts/index/institution_accounts", institution: %> + <% if @plaid_items.any? %> + <%= render @plaid_items.sort_by(&:created_at) %> <% end %> - <% if @accounts.any? %> - <%= render "accounts/index/institutionless_accounts", accounts: @accounts %> + <% if @manual_accounts.any? %> + <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> <% end %>
<% end %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/index/_account_groups.erb similarity index 85% rename from app/views/accounts/_accountable_group.html.erb rename to app/views/accounts/index/_account_groups.erb index bef3069e..0b26b618 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -1,6 +1,6 @@ <%# locals: (accounts:) %> -<% accounts.group_by(&:accountable_type).each do |group, accounts| %> +<% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %>

<%= to_accountable_title(Accountable.from_type(group)) %>

diff --git a/app/views/accounts/index/_institution_accounts.html.erb b/app/views/accounts/index/_institution_accounts.html.erb deleted file mode 100644 index a1127077..00000000 --- a/app/views/accounts/index/_institution_accounts.html.erb +++ /dev/null @@ -1,91 +0,0 @@ -<%# locals: (institution:) %> - -
- -
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> - -
- <% if institution_logo(institution) %> - <%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %> - <% else %> -
- <%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> -
- <% end %> -
- -
- <%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %> - <% if institution.has_issues? %> -
- <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> -
- <% elsif institution.syncing? %> -
- <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> - <%= tag.span t(".syncing") %> -
- <% else %> -

<%= institution.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(institution.last_synced_at)) : t(".status_never") %>

- <% end %> -
-
- -
- <%= button_to sync_institution_path(institution), method: :post, class: "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 %> - - <%= contextual_menu do %> -
- <%= link_to new_account_path(institution_id: institution.id, return_to: accounts_path), - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", - data: { turbo_frame: :modal } do %> - <%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %> - - <%= t(".add_account_to_institution") %> - <% end %> - - <%= link_to edit_institution_path(institution), - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", - data: { turbo_frame: :modal } do %> - <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> - - <%= t(".edit") %> - <% end %> - - <%= button_to institution_path(institution), - 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", - data: { - turbo_confirm: { - title: t(".confirm_title"), - body: t(".confirm_body"), - accept: t(".confirm_accept") - } - } do %> - <%= lucide_icon "trash-2", class: "w-5 h-5" %> - - <%= t(".delete") %> - <% end %> -
- <% end %> -
-
- -
- <% if institution.accounts.any? %> - <%= render "accountable_group", accounts: institution.accounts %> - <% else %> -
-

There are no accounts in this financial institution

- <%= link_to new_account_path(institution_id: institution.id, return_to: accounts_path), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-1.5 pr-2", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-4 h-4") %> - <%= t(".new_account") %> - <% end %> -
- <% end %> -
-
diff --git a/app/views/accounts/index/_institutionless_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb similarity index 89% rename from app/views/accounts/index/_institutionless_accounts.html.erb rename to app/views/accounts/index/_manual_accounts.html.erb index 335e842e..9fe0779a 100644 --- a/app/views/accounts/index/_institutionless_accounts.html.erb +++ b/app/views/accounts/index/_manual_accounts.html.erb @@ -12,6 +12,6 @@
- <%= render "accountable_group", accounts: accounts %> + <%= render "accounts/index/account_groups", accounts: accounts %>
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 2760ff4f..4c32ede4 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:) %> +<%# locals: (path:, link_token: nil) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
@@ -9,11 +9,13 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - - - <%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %> - - <%= t("accounts.new.method_selector.connected_entry") %> - + <% if link_token.present? %> + + <% end %>
<% end %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index de5955b0..1a2b39a5 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,8 +20,10 @@ <% end %>
- <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %> - <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% unless account.plaid_account_id.present? %> + <%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% end %> <% end %> <%= render "accounts/show/menu", account: account %> diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 4ff93e02..1c601032 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -2,23 +2,32 @@ <%= contextual_menu do %>
- <%= link_to edit_account_path(account), + <% if account.plaid_account_id.present? %> + <%= link_to accounts_path, + data: { turbo_frame: :_top }, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + + <%= t(".manage") %> + <% end %> + <% else %> + <%= link_to edit_account_path(account), data: { turbo_frame: :modal }, class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> - <%= t(".edit") %> - <% end %> + <%= t(".edit") %> + <% end %> - <%= link_to new_import_path, + <%= link_to new_import_path, data: { turbo_frame: :modal }, class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> + <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> - <%= t(".import") %> - <% end %> + <%= t(".import") %> + <% end %> - <%= button_to account_path(account), + <%= button_to account_path(account), 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", data: { @@ -29,7 +38,8 @@ accept: t(".confirm_accept", name: account.name) } } do %> - <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account + <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account + <% end %> <% end %>
<% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 0e13c2a5..9297f2ae 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(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), link_token: @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 38cb112b..ff9e1d07 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(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), link_token: @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 db8f4ff5..9247ca2c 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(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "depositories/form", account: @account, url: depositories_path %> diff --git a/app/views/institutions/_form.html.erb b/app/views/institutions/_form.html.erb deleted file mode 100644 index dd72fb34..00000000 --- a/app/views/institutions/_form.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= styled_form_with model: institution, class: "space-y-4", data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %> -
- <%= f.label :logo do %> -
- <% persisted_logo = institution_logo(institution) %> - - <% if persisted_logo %> - <%= image_tag persisted_logo, class: "absolute inset-0 rounded-full w-full h-full object-cover" %> - <% end %> - -
- <% unless persisted_logo %> - <%= lucide_icon "image-plus", class: "w-5 h-5 text-gray-500 cursor-pointer", data: { profile_image_preview_target: "template" } %> - <% end %> -
-
- <% end %> -
- - <%= f.file_field :logo, - accept: "image/png, image/jpeg", - class: "hidden", - data: { profile_image_preview_target: "fileField", action: "profile-image-preview#preview" } %> - <%= f.text_field :name, label: t(".name") %> - <%= f.submit %> -<% end %> diff --git a/app/views/institutions/edit.html.erb b/app/views/institutions/edit.html.erb deleted file mode 100644 index 75c81579..00000000 --- a/app/views/institutions/edit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %> - <%= render "form", institution: @institution %> -<% end %> diff --git a/app/views/institutions/new.html.erb b/app/views/institutions/new.html.erb deleted file mode 100644 index 94c36193..00000000 --- a/app/views/institutions/new.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".new_institution") do %> - <%= render "form", institution: @institution %> -<% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb index bb96b3bc..9f88954e 100644 --- a/app/views/investments/_chart.html.erb +++ b/app/views/investments/_chart.html.erb @@ -1 +1,25 @@ <%# locals: (account:) %> + +<% period = Period.from_param(params[:period]) %> +<% series = account.series(period: period) %> +<% trend = series.trend %> + +
+
+
+
+ <%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %> +
+ + <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> +
+
+ +
+ <%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %> +
+

Historical investment data coming soon.

+

We're working to bring you the full picture.

+
+
+
diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index 5e83350d..532a168c 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(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "investments/form", account: @account, url: investments_path %> diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 4833badd..36f1b934 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -4,7 +4,10 @@ <%= tag.div class: "space-y-4" do %> <%= render "accounts/show/header", account: @account %> - <%= render "accounts/show/chart", + <% if @account.plaid_account_id.present? %> + <%= render "investments/chart", account: @account %> + <% else %> + <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), tooltip: render( @@ -12,6 +15,7 @@ value: @account.value, cash: @account.balance_money ) %> + <% end %>
<%= render "accounts/show/tabs", account: @account, tabs: [ diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1c4d3b4e..305987c3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,6 +9,7 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> <%= javascript_importmap_tags %> @@ -30,9 +31,13 @@ <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %> <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> -
-
+
+
<%= render_flash_notifications %> + + <% if Current.family&.syncing? %> + <%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %> + <% end %>
diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index 407e648d..110a68bd 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(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "loans/form", account: @account, url: loans_path %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb new file mode 100644 index 00000000..01330318 --- /dev/null +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -0,0 +1,76 @@ +<%# locals: (plaid_item:) %> + +<%= tag.div id: dom_id(plaid_item) do %> +
+ +
+ <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> + +
+ <% if plaid_item.logo.attached? %> + <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %> + <% else %> +
+ <%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> +
+ <% end %> +
+ +
+ <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> + <% if plaid_item.syncing? %> +
+ <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> + <%= tag.span t(".syncing") %> +
+ <% elsif plaid_item.sync_error.present? %> +
+ <%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %> + <%= tag.span t(".error"), class: "text-red-500" %> +
+ <% else %> +

+ <%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> +

+ <% end %> +
+
+ +
+ <%= 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 %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> + <% end %> + + <%= contextual_menu do %> +
+ <%= 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", + data: { + turbo_confirm: { + title: t(".confirm_title"), + body: t(".confirm_body"), + accept: t(".confirm_accept") + } + } do %> + <%= lucide_icon "trash-2", class: "w-5 h-5" %> + + <%= t(".delete") %> + <% end %> +
+ <% end %> +
+
+ +
+ <% 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 %> diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb index 7f4cef0c..6f6ed438 100644 --- a/app/views/shared/_notification.html.erb +++ b/app/views/shared/_notification.html.erb @@ -4,7 +4,7 @@ <% action = "animationend->element-removal#remove" if type == :notice %> <%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25", - id: id, + id: type == :processing ? "syncing-notification" : id, data: { controller: "element-removal", action: action diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index e8cd3ea1..dad6157b 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -12,7 +12,7 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> - <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> <%= f.money_field :amount, label: t(".amount"), required: true %> <%= f.hidden_field :entryable_type, value: "Account::Transaction" %> <%= f.fields_for :entryable do |ef| %> diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 024d5e68..142afb99 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -KRWOjsn+8XsQvywXPDphgdRfbxouM6sML006oZNoiXQE3QMj/B7ee53nsT40Pgt37yyu6Adn84SZTkOs0sbOtHlEKQOr8fagJ96bZsQiIqtWA3JIDrHSVygssRIYVD8LyB1ezS+Oa4rZh4NsGJGQdyxlyUVmdFpqT6s18ptFnraDJuf54pkPyl5zAxtIVufXWO2wbyXryE2XEoYfHNmuPO3rtvXOQS9gEYe5yxh1EqgTG32UKnwxCNXBCksgrzuy9qebVTiBRun1L2S7diRw94dZ1mgkIweDAJCwO5wBtgfqo8DWBPunKbqJ5gwRJTvELDXXCWcnPjCUGPSPPBw4clUXNbjkxMltFlC5EReiTb7fi2rRGM64cRZlgReh8RVy6pyiKM2tHUI3Tmdk4q7nwTBCy6ot--ZUWi92DcBx1HlcZg--SVhHEO5AJCLD9LlhuXA+kA== \ No newline at end of file +HMC62biPQuF61XA8tnd/kvwdV2xr/zpfJxG+IHNgGtpuvPXi9oS+YemBGMLte+1Q7elzAAbmKg73699hVLkRcBCk/FaMQjGRF2lnJ9MpxSR/br8Uma2bSH40lIEjxAfzjr4JPSfsHxlArF30hfd+B9obPDOptLQbpENPBsmiuEHX7S0Y8SmKuzDUVrvdfeLoVuMiAZqOP5izpBAbXfvMjI3YH70iJAaPlfAxQqR89O2nSt+N27siyyfkypE3NHQKZFz+Rmo8uJDlaD3eo/uvQN4xsgRCMUar4X2iY4UOd+MIGAPqLzIUhhJ56G5MRDJ4XpJA6RDuGFc/LNyxdXt0WinUX8Yz7zKiKah1NkEhTkH+b2ylFbsN6cjlqcX0yw8Gw8B4osyHQGnj7Tuf1c8k1z3gBoaQALm8zxKCaJ9k6CopVM2GmbpCLcJqjN1L71wCe6MiWsv9LDF/pwuZNG6hWn0oykdkWeBEQyK8g4Wo1AHqgEi8XtRwbaX6yugO5WQFhjQG/LzXcG02E5Co5/r/G7ZSFpRC9ngoOx3LY6MihPRkTIOumCg3HHtAsWBeHe4L/rDIe4A=--hlLxVbnyuYXf7Rku--A6Cwdr3CAW6bRkl1rcRmRw== \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index ea7cd99d..c032dfed 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -94,4 +94,6 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + config.active_record.encryption = Rails.application.credentials.active_record_encryption end diff --git a/config/environments/test.rb b/config/environments/test.rb index c9918cbc..37637b90 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,5 +63,10 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + config.active_record.encryption.primary_key = "test" + config.active_record.encryption.deterministic_key = "test" + config.active_record.encryption.key_derivation_salt = "test" + config.active_record.encryption.encrypt_fixtures = true + config.autoload_paths += %w[test/support] end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 458a8c08..bf7d512b 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -26,8 +26,6 @@ search: - app/assets/fonts - app/assets/videos - app/assets/builds -ignore_missing: - - 'accountable_resource.{create,update,destroy}.success' ignore_unused: - 'activerecord.attributes.*' # i18n-tasks does not detect these on forms, forms validations (https://github.com/glebm/i18n-tasks/blob/0b4b483c82664f26c5696fb0f6aa1297356e4683/templates/config/i18n-tasks.yml#L146) - 'activerecord.models.*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human) diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb new file mode 100644 index 00000000..b0631110 --- /dev/null +++ b/config/initializers/plaid.rb @@ -0,0 +1,10 @@ +Rails.application.configure do + config.plaid = nil + + if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present? + config.plaid = Plaid::Configuration.new + config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] + config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] + config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] + end +end diff --git a/config/locales/models/account/sync/en.yml b/config/locales/models/account/sync/en.yml deleted file mode 100644 index 324ea42e..00000000 --- a/config/locales/models/account/sync/en.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -en: - account: - sync: - failed: Sync failed diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index e117e90d..b742a731 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -16,7 +16,7 @@ en: new: New new_balance: New balance new_transaction: New transaction - no_entries: No entries found + no_entries: No entries found title: Activity loading: loading: Loading entries... diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index ad5eafac..99fb3a42 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -9,8 +9,6 @@ en: cost: cost holdings: Holdings name: name - needs_sync: Your account needs to sync the latest prices to calculate this - portfolio new_holding: New transaction no_holdings: No holdings to show. return: total return diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml index 0db9db32..15b83280 100644 --- a/config/locales/views/account/trades/en.yml +++ b/config/locales/views/account/trades/en.yml @@ -44,12 +44,5 @@ en: settings: Settings symbol_label: Symbol total_return_label: Unrealized gain/loss - trade: - buy: Buy - deposit: Deposit - inflow: Inflow - outflow: Outflow - sell: Sell - withdrawal: Withdrawal update: success: Trade updated successfully. diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index b3dcb1af..585cb37d 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -6,40 +6,28 @@ en: troubleshoot: Troubleshoot account_list: new_account: New %{type} + create: + success: "%{type} account created" + destroy: + success: "%{type} account scheduled for deletion" empty: empty_message: Add an account either via connection, importing or entering manually. new_account: New account no_accounts: No accounts yet form: balance: Current balance - institution: Financial institution name_label: Account name name_placeholder: Example account name - ungrouped: "(none)" index: accounts: Accounts - add_institution: Add institution - institution_accounts: - add_account_to_institution: Add new account - confirm_accept: Delete institution - confirm_body: Don't worry, none of the accounts within this institution will - be affected by this deletion. Accounts will be ungrouped and all historical - data will remain intact. - confirm_title: Delete financial institution? - delete: Delete institution - edit: Edit institution - has_issues: Issue detected, see accounts - new_account: Add account - status: Last synced %{last_synced_at} ago - status_never: Requires data sync - syncing: Syncing... - institutionless_accounts: + manual_accounts: other_accounts: Other accounts new_account: New account + sync: Sync all new: import_accounts: Import accounts method_selector: - connected_entry: Link account (coming soon) + connected_entry: Link account manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? @@ -58,6 +46,7 @@ en: confirm_title: Delete account? edit: Edit import: Import transactions + manage: Manage accounts summary: header: accounts: Accounts @@ -70,7 +59,5 @@ en: no_liabilities: No liabilities found no_liabilities_description: Add a liability either via connection, importing or entering manually. - sync_all: - success: Successfully queued accounts for syncing. - sync_all_button: - sync: Sync all + update: + success: "%{type} account updated" diff --git a/config/locales/views/credit_cards/en.yml b/config/locales/views/credit_cards/en.yml index 53ff3163..f0131d21 100644 --- a/config/locales/views/credit_cards/en.yml +++ b/config/locales/views/credit_cards/en.yml @@ -1,10 +1,6 @@ --- en: credit_cards: - create: - success: Credit card account created - destroy: - success: Credit card account deleted edit: edit: Edit %{account} form: @@ -27,5 +23,3 @@ en: expiration_date: Expiration Date minimum_payment: Minimum Payment unknown: Unknown - update: - success: Credit card account updated diff --git a/config/locales/views/cryptos/en.yml b/config/locales/views/cryptos/en.yml index 3e8619af..298cc9cc 100644 --- a/config/locales/views/cryptos/en.yml +++ b/config/locales/views/cryptos/en.yml @@ -1,13 +1,7 @@ --- en: cryptos: - create: - success: Crypto account created - destroy: - success: Crypto account deleted edit: edit: Edit %{account} new: title: Enter account balance - update: - success: Crypto account updated diff --git a/config/locales/views/depositories/en.yml b/config/locales/views/depositories/en.yml index 38a91c29..081d1467 100644 --- a/config/locales/views/depositories/en.yml +++ b/config/locales/views/depositories/en.yml @@ -1,10 +1,6 @@ --- en: depositories: - create: - success: Depository account created - destroy: - success: Depository account deleted edit: edit: Edit %{account} form: @@ -12,5 +8,3 @@ en: subtype_prompt: Select account type new: title: Enter account balance - update: - success: Depository account updated diff --git a/config/locales/views/institutions/en.yml b/config/locales/views/institutions/en.yml deleted file mode 100644 index d90aec86..00000000 --- a/config/locales/views/institutions/en.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -en: - institutions: - create: - success: Institution created - destroy: - success: Institution deleted - edit: - edit: Edit %{institution} - form: - name: Financial institution name - new: - new_institution: New financial institution - sync: - success: Institution sync started - update: - success: Institution updated diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index ed83fb05..ec2cbe86 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,10 +1,8 @@ --- en: investments: - create: - success: Investment account created - destroy: - success: Investment account deleted + chart: + value: Total value edit: edit: Edit %{account} form: @@ -14,8 +12,6 @@ en: title: Enter account balance show: chart_title: Total value - update: - success: Investment account updated value_tooltip: cash: Cash holdings: Holdings diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 8adc4792..c0598db9 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -1,6 +1,8 @@ --- en: layouts: + application: + syncing: Syncing account data... auth: existing_account: Already have an account? no_account: New to Maybe? diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index ae143a7d..930af8df 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -1,10 +1,6 @@ --- en: loans: - create: - success: Loan account created - destroy: - success: Loan account deleted edit: edit: Edit %{account} form: @@ -24,5 +20,3 @@ en: term: Term type: Type unknown: Unknown - update: - success: Loan account updated diff --git a/config/locales/views/other_assets/en.yml b/config/locales/views/other_assets/en.yml index ce079a1d..be3f0f43 100644 --- a/config/locales/views/other_assets/en.yml +++ b/config/locales/views/other_assets/en.yml @@ -1,13 +1,7 @@ --- en: other_assets: - create: - success: Other asset account created - destroy: - success: Other asset account deleted edit: edit: Edit %{account} new: title: Enter asset details - update: - success: Other asset account updated diff --git a/config/locales/views/other_liabilities/en.yml b/config/locales/views/other_liabilities/en.yml index 332c3846..9008481e 100644 --- a/config/locales/views/other_liabilities/en.yml +++ b/config/locales/views/other_liabilities/en.yml @@ -1,13 +1,7 @@ --- en: other_liabilities: - create: - success: Other liability account created - destroy: - success: Other liability account deleted edit: edit: Edit %{account} new: title: Enter liability details - update: - success: Other liability account updated diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml new file mode 100644 index 00000000..d4af2bf8 --- /dev/null +++ b/config/locales/views/plaid_items/en.yml @@ -0,0 +1,20 @@ +--- +en: + plaid_items: + create: + success: Account linked successfully. Please wait for accounts to sync. + destroy: + success: Accounts scheduled for deletion. + plaid_item: + confirm_accept: Delete institution + confirm_body: This will permanently delete all the accounts in this group and + all associated data. + confirm_title: Delete institution? + delete: Delete + error: Error occurred while syncing data + no_accounts_description: We could not load any accounts from this financial + institution. + no_accounts_title: No accounts found + status: Last synced %{timestamp} ago + status_never: Requires data sync + syncing: Syncing... diff --git a/config/locales/views/properties/en.yml b/config/locales/views/properties/en.yml index 0558b5e6..289335bb 100644 --- a/config/locales/views/properties/en.yml +++ b/config/locales/views/properties/en.yml @@ -1,10 +1,6 @@ --- en: properties: - create: - success: Property account created - destroy: - success: Property account deleted edit: edit: Edit %{account} form: @@ -34,5 +30,3 @@ en: trend: Trend unknown: Unknown year_built: Year Built - update: - success: Property account updated diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index cf4b77f2..d79c10ff 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -9,9 +9,9 @@ en: create: Continue registrations: create: + failure: There was a problem signing up. invalid_invite_code: Invalid invite code, please try again. success: You have signed up successfully. - failure: There was a problem signing up. new: invitation_message: "%{inviter} has invited you to join as a %{role}" join_family_title: Join %{family} diff --git a/config/locales/views/vehicles/en.yml b/config/locales/views/vehicles/en.yml index ef5a13c4..03ed5225 100644 --- a/config/locales/views/vehicles/en.yml +++ b/config/locales/views/vehicles/en.yml @@ -1,10 +1,6 @@ --- en: vehicles: - create: - success: Vehicle account created - destroy: - success: Vehicle account deleted edit: edit: Edit %{account} form: @@ -27,5 +23,3 @@ en: trend: Trend unknown: Unknown year: Year - update: - success: Vehicle account updated diff --git a/config/routes.rb b/config/routes.rb index 4f72e198..1462b71a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,9 +114,6 @@ Rails.application.routes.draw do end end - resources :institutions, except: %i[index show] do - post :sync, on: :member - end resources :invite_codes, only: %i[index create] resources :issues, only: :show @@ -150,8 +147,14 @@ Rails.application.routes.draw do end end - # Stripe webhook endpoint - post "webhooks/stripe", to: "webhooks#stripe" + resources :plaid_items, only: %i[create destroy] do + post :sync, on: :member + end + + namespace :webhooks do + post "plaid" + post "stripe" + end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb new file mode 100644 index 00000000..582b8e9d --- /dev/null +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -0,0 +1,56 @@ +class AddPlaidDomain < ActiveRecord::Migration[7.2] + def change + create_table :plaid_items, id: :uuid do |t| + t.references :family, null: false, type: :uuid, foreign_key: true + t.string :access_token + t.string :plaid_id + t.string :name + t.string :next_cursor + t.boolean :scheduled_for_deletion, default: false + + t.timestamps + end + + create_table :plaid_accounts, id: :uuid do |t| + t.references :plaid_item, null: false, type: :uuid, foreign_key: true + t.string :plaid_id + t.string :plaid_type + t.string :plaid_subtype + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :available_balance, precision: 19, scale: 4 + t.string :currency + t.string :name + t.string :mask + + t.timestamps + end + + create_table :syncs, id: :uuid do |t| + t.references :syncable, polymorphic: true, null: false, type: :uuid + t.datetime :last_ran_at + t.date :start_date + t.string :status, default: "pending" + t.string :error + t.jsonb :data + + t.timestamps + end + + remove_column :families, :last_synced_at, :datetime + add_column :families, :last_auto_synced_at, :datetime + remove_column :accounts, :last_sync_date, :date + remove_reference :accounts, :institution + add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true + + add_column :account_entries, :plaid_id, :string + add_column :accounts, :scheduled_for_deletion, :boolean, default: false + + drop_table :account_syncs do |t| + t.timestamps + end + + drop_table :institutions do |t| + t.timestamps + end + end +end diff --git a/db/migrate/20241114164118_add_products_to_plaid_item.rb b/db/migrate/20241114164118_add_products_to_plaid_item.rb new file mode 100644 index 00000000..d19f53c3 --- /dev/null +++ b/db/migrate/20241114164118_add_products_to_plaid_item.rb @@ -0,0 +1,10 @@ +class AddProductsToPlaidItem < ActiveRecord::Migration[7.2] + def change + add_column :plaid_items, :available_products, :string, array: true, default: [] + add_column :plaid_items, :billed_products, :string, array: true, default: [] + + rename_column :families, :last_auto_synced_at, :last_synced_at + add_column :plaid_items, :last_synced_at, :datetime + add_column :accounts, :last_synced_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 4329c8c7..fa9dea5a 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: 2024_11_08_150422) do +ActiveRecord::Schema[7.2].define(version: 2024_11_14_164118) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -46,6 +46,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.uuid "import_id" t.text "notes" t.boolean "excluded", default: false + t.string "plaid_id" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" @@ -66,17 +67,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.index ["security_id"], name: "index_account_holdings_on_security_id" end - create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "status", default: "pending", null: false - t.date "start_date" - t.datetime "last_ran_at" - t.string "error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_account_syncs_on_account_id" - end - create_table "account_trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "security_id", null: false t.decimal "qty", precision: 19, scale: 4 @@ -117,17 +107,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.date "last_sync_date" - t.uuid "institution_id" t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" + t.uuid "plaid_account_id" + t.boolean "scheduled_for_deletion", default: false + t.datetime "last_synced_at" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" - t.index ["institution_id"], name: "index_accounts_on_institution_id" + t.index ["plaid_account_id"], name: "index_accounts_on_plaid_account_id" end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -220,13 +211,13 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.datetime "last_synced_at" t.string "locale", default: "en" t.string "stripe_plan_id" t.string "stripe_customer_id" t.string "stripe_subscription_status", default: "incomplete" t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" + t.datetime "last_synced_at" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -402,16 +393,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.index ["family_id"], name: "index_imports_on_family_id" end - create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "logo_url" - t.uuid "family_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "last_synced_at" - t.index ["family_id"], name: "index_institutions_on_family_id" - end - create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -481,6 +462,36 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.datetime "updated_at", null: false end + create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "plaid_item_id", null: false + t.string "plaid_id" + t.string "plaid_type" + t.string "plaid_subtype" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "currency" + t.string "name" + t.string "mask" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" + end + + create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "access_token" + t.string "plaid_id" + t.string "name" + t.string "next_cursor" + t.boolean "scheduled_for_deletion", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "available_products", default: [], array: true + t.string "billed_products", default: [], array: true + t.datetime "last_synced_at" + t.index ["family_id"], name: "index_plaid_items_on_family_id" + end + create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -553,6 +564,19 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" end + create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "syncable_type", null: false + t.uuid "syncable_id", null: false + t.datetime "last_ran_at" + t.date "start_date" + t.string "status", default: "pending" + t.string "error" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" + end + create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "tag_id", null: false t.string "taggable_type" @@ -605,13 +629,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do add_foreign_key "account_entries", "imports" add_foreign_key "account_holdings", "accounts" add_foreign_key "account_holdings", "securities" - add_foreign_key "account_syncs", "accounts" add_foreign_key "account_trades", "securities" add_foreign_key "account_transactions", "categories", on_delete: :nullify add_foreign_key "account_transactions", "merchants" add_foreign_key "accounts", "families" add_foreign_key "accounts", "imports" - add_foreign_key "accounts", "institutions" + add_foreign_key "accounts", "plaid_accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "categories", "families" @@ -620,10 +643,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" - add_foreign_key "institutions", "families" add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" + add_foreign_key "plaid_accounts", "plaid_items" + add_foreign_key "plaid_items", "families" 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/application_system_test_case.rb b/test/application_system_test_case.rb index 80b2711b..83b45e4f 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -3,9 +3,6 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase setup do Capybara.default_max_wait_time = 5 - - # Prevent "auto sync" from running when tests execute enqueued jobs - families(:dylan_family).update! last_synced_at: Time.now end driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ] diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index b8b38357..d735eb01 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -19,7 +19,7 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to account_url(entry.account) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end @@ -51,7 +51,7 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to account_entry_url(entry.account, entry) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index f45e1b4b..43f20720 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -109,7 +109,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.positive? assert created_entry.account_trade.qty.positive? assert_equal "Transaction created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @entry.account end @@ -132,7 +132,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.negative? assert created_entry.account_trade.qty.negative? assert_equal "Transaction created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @entry.account end end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index f5290229..ddda4677 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -35,6 +35,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal "Transaction updated successfully.", flash[:notice] assert_redirected_to account_entry_url(@entry.account, @entry) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/account/transfers_controller_test.rb b/test/controllers/account/transfers_controller_test.rb index 671147e0..72e14345 100644 --- a/test/controllers/account/transfers_controller_test.rb +++ b/test/controllers/account/transfers_controller_test.rb @@ -21,7 +21,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest name: "Test Transfer" } } - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob end end diff --git a/test/controllers/account/valuations_controller_test.rb b/test/controllers/account/valuations_controller_test.rb index eed2a33f..1d3daeb7 100644 --- a/test/controllers/account/valuations_controller_test.rb +++ b/test/controllers/account/valuations_controller_test.rb @@ -29,7 +29,7 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest end assert_equal "Valuation created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to account_valuations_path(@entry.account) end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 18ff5c7d..a2312018 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -10,9 +10,13 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest get accounts_url assert_response :success - @user.family.accounts.each do |account| + @user.family.accounts.manual.each do |account| assert_dom "#" + dom_id(account), count: 1 end + + @user.family.plaid_items.each do |item| + assert_dom "#" + dom_id(item), count: 1 + end end test "new" do @@ -22,12 +26,11 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync an account" do post sync_account_path(@account) - assert_response :no_content + assert_redirected_to account_path(@account) end test "can sync all accounts" do post sync_all_accounts_path - assert_redirected_to accounts_url - assert_equal "Successfully queued accounts for syncing.", flash[:notice] + assert_redirected_to accounts_path end end diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index dfd1b5b6..8c119e8b 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -43,7 +43,7 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Credit card account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with credit card details" do @@ -78,6 +78,6 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Credit card account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/institutions_controller_test.rb b/test/controllers/institutions_controller_test.rb deleted file mode 100644 index ce5d9664..00000000 --- a/test/controllers/institutions_controller_test.rb +++ /dev/null @@ -1,62 +0,0 @@ -require "test_helper" - -class InstitutionsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - @institution = institutions(:chase) - end - - test "should get new" do - get new_institution_url - assert_response :success - end - - test "can create institution" do - assert_difference("Institution.count", 1) do - post institutions_url, params: { - institution: { - name: "New institution" - } - } - end - - assert_redirected_to accounts_url - assert_equal "Institution created", flash[:notice] - end - - test "should get edit" do - get edit_institution_url(@institution) - - assert_response :success - end - - test "should update institution" do - patch institution_url(@institution), params: { - institution: { - name: "New Institution Name", - logo: file_fixture_upload("square-placeholder.png", "image/png", :binary) - } - } - - assert_redirected_to accounts_url - assert_equal "Institution updated", flash[:notice] - end - - test "can destroy institution without destroying accounts" do - assert @institution.accounts.count > 0 - - assert_difference -> { Institution.count } => -1, -> { Account.count } => 0 do - delete institution_url(@institution) - end - - assert_redirected_to accounts_url - assert_equal "Institution deleted", flash[:notice] - end - - test "can sync institution" do - post sync_institution_url(@institution) - - assert_redirected_to accounts_url - assert_equal "Institution sync started", flash[:notice] - end -end diff --git a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb index 01d48ce3..6b884014 100644 --- a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb +++ b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb @@ -13,7 +13,7 @@ class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::Integr } } - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @issue.issuable end end diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb index 7df26b81..627d82ac 100644 --- a/test/controllers/loans_controller_test.rb +++ b/test/controllers/loans_controller_test.rb @@ -39,7 +39,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Loan account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with loan details" do @@ -70,6 +70,6 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Loan account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb new file mode 100644 index 00000000..7f9a3afe --- /dev/null +++ b/test/controllers/plaid_items_controller_test.rb @@ -0,0 +1,49 @@ +require "test_helper" +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 + public_token = "public-sandbox-1234" + + @plaid_provider.expects(:exchange_public_token).with(public_token).returns( + OpenStruct.new(access_token: "access-sandbox-1234", item_id: "item-sandbox-1234") + ) + + assert_difference "PlaidItem.count", 1 do + post plaid_items_url, params: { + plaid_item: { + public_token: public_token, + metadata: { institution: { name: "Plaid Item Name" } } + } + } + end + + assert_equal "Account linked successfully. Please wait for accounts to sync.", flash[:notice] + assert_redirected_to accounts_path + end + + test "destroy" do + delete plaid_item_url(plaid_items(:one)) + + assert_equal "Accounts scheduled for deletion.", flash[:notice] + assert_enqueued_with job: DestroyJob + assert_redirected_to accounts_path + end + + test "sync" do + plaid_item = plaid_items(:one) + PlaidItem.any_instance.expects(:sync_later).once + + post sync_plaid_item_url(plaid_item) + + assert_redirected_to accounts_path + end +end diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb index 46412060..9eaf63e8 100644 --- a/test/controllers/properties_controller_test.rb +++ b/test/controllers/properties_controller_test.rb @@ -43,7 +43,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Property account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with property details" do @@ -74,6 +74,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Property account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 09b31910..5b73c114 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -37,7 +37,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount assert_equal "New transaction created successfully", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) assert_redirected_to account_url(account) end diff --git a/test/controllers/vehicles_controller_test.rb b/test/controllers/vehicles_controller_test.rb index 00df8f36..3ebd23e3 100644 --- a/test/controllers/vehicles_controller_test.rb +++ b/test/controllers/vehicles_controller_test.rb @@ -40,7 +40,7 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Vehicle account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with vehicle details" do @@ -66,6 +66,6 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Vehicle account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/fixtures/account/syncs.yml b/test/fixtures/account/syncs.yml deleted file mode 100644 index f042d3b1..00000000 --- a/test/fixtures/account/syncs.yml +++ /dev/null @@ -1,12 +0,0 @@ -one: - account: depository - status: failed - start_date: 2024-07-07 - last_ran_at: 2024-07-07 09:03:31 - error: test sync error - -two: - account: investment - status: completed - start_date: 2024-07-07 - last_ran_at: 2024-07-07 09:03:32 diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 5c33a1ef..1bb48c6e 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -21,7 +21,15 @@ depository: currency: USD accountable_type: Depository accountable: one - institution: chase + +connected: + family: dylan_family + name: Connected Account + balance: 5000 + currency: USD + accountable_type: Depository + accountable: two + plaid_account: one credit_card: family: dylan_family @@ -30,7 +38,6 @@ credit_card: currency: USD accountable_type: CreditCard accountable: one - institution: chase investment: family: dylan_family diff --git a/test/fixtures/depositories.yml b/test/fixtures/depositories.yml index e0553ab0..acfb5a66 100644 --- a/test/fixtures/depositories.yml +++ b/test/fixtures/depositories.yml @@ -1 +1,2 @@ -one: { } \ No newline at end of file +one: { } +two: {} \ No newline at end of file diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 57697046..375bb175 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -1,8 +1,10 @@ empty: name: Family stripe_subscription_status: active + last_synced_at: <%= Time.now %> dylan_family: name: The Dylan Family stripe_subscription_status: active + last_synced_at: <%= Time.now %> diff --git a/test/fixtures/institutions.yml b/test/fixtures/institutions.yml deleted file mode 100644 index 18625a91..00000000 --- a/test/fixtures/institutions.yml +++ /dev/null @@ -1,8 +0,0 @@ -chase: - name: Chase - family: dylan_family - -revolut: - name: Revolut - family: dylan_family - logo_url: <%= "file://" + Rails.root.join('test/fixtures/files/square-placeholder.png').to_s %> diff --git a/test/fixtures/plaid_accounts.yml b/test/fixtures/plaid_accounts.yml new file mode 100644 index 00000000..2a911104 --- /dev/null +++ b/test/fixtures/plaid_accounts.yml @@ -0,0 +1,3 @@ +one: + plaid_item: one + plaid_id: "1234567890" diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml new file mode 100644 index 00000000..21a0b460 --- /dev/null +++ b/test/fixtures/plaid_items.yml @@ -0,0 +1,5 @@ +one: + family: dylan_family + plaid_id: "1234567890" + access_token: encrypted_token_1 + name: "Test Bank" \ No newline at end of file diff --git a/test/fixtures/syncs.yml b/test/fixtures/syncs.yml new file mode 100644 index 00000000..1b010568 --- /dev/null +++ b/test/fixtures/syncs.yml @@ -0,0 +1,17 @@ +account: + syncable_type: Account + syncable: depository + last_ran_at: <%= Time.now %> + status: completed + +plaid_item: + syncable_type: PlaidItem + syncable: one + last_ran_at: <%= Time.now %> + status: completed + +family: + syncable_type: Family + syncable: dylan_family + last_ran_at: <%= Time.now %> + status: completed diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index ce0a6871..c1fe9208 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -4,6 +4,10 @@ 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") + ) + get new_polymorphic_url(@account.accountable) assert_response :success end @@ -21,14 +25,14 @@ module AccountableResourceInterfaceTest test "destroys account" do delete account_url(@account) assert_redirected_to accounts_path - assert_equal "#{@account.accountable_name.humanize} account deleted", flash[:notice] + assert_enqueued_with job: DestroyJob + assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice] end test "updates basic account balances" do assert_no_difference [ "Account.count", "@account.accountable_class.count" ] do patch account_url(@account), params: { account: { - institution_id: institutions(:chase).id, name: "Updated name", balance: 10000, currency: "USD" @@ -37,7 +41,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] + assert_equal "#{@account.accountable_name.underscore.humanize} account updated", flash[:notice] end test "creates with basic attributes" do @@ -45,7 +49,6 @@ module AccountableResourceInterfaceTest post "/#{@account.accountable_name.pluralize}", params: { account: { accountable_type: @account.accountable_class, - institution_id: institutions(:chase).id, name: "New accountable", balance: 10000, currency: "USD", @@ -68,7 +71,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] end @@ -84,7 +87,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] end end diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb new file mode 100644 index 00000000..6613dbcc --- /dev/null +++ b/test/interfaces/syncable_interface_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +module SyncableInterfaceTest + extend ActiveSupport::Testing::Declarative + include ActiveJob::TestHelper + + test "can sync later" do + assert_difference "@syncable.syncs.count", 1 do + assert_enqueued_with job: SyncJob do + @syncable.sync_later + end + end + end + + test "can sync" do + assert_difference "@syncable.syncs.count", 1 do + @syncable.sync(start_date: 2.days.ago.to_date) + end + end + + test "implements sync_data" do + assert_respond_to @syncable, :sync_data + end +end diff --git a/test/jobs/account_balance_sync_job_test.rb b/test/jobs/account_balance_sync_job_test.rb deleted file mode 100644 index af5d627e..00000000 --- a/test/jobs/account_balance_sync_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class Account::BalanceSyncJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/convert_currency_job_test.rb b/test/jobs/convert_currency_job_test.rb deleted file mode 100644 index a208d53e..00000000 --- a/test/jobs/convert_currency_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class ConvertCurrencyJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/daily_exchange_rate_job_test.rb b/test/jobs/daily_exchange_rate_job_test.rb deleted file mode 100644 index 7308046a..00000000 --- a/test/jobs/daily_exchange_rate_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class DailyExchangeRateJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb new file mode 100644 index 00000000..b8d34400 --- /dev/null +++ b/test/jobs/sync_job_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class SyncJobTest < ActiveJob::TestCase + test "sync is performed" do + syncable = accounts(:depository) + + sync = syncable.syncs.create!(start_date: 2.days.ago.to_date) + + sync.expects(:perform).once + + SyncJob.perform_now(sync) + end +end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb deleted file mode 100644 index 75b6eaec..00000000 --- a/test/models/account/sync_test.rb +++ /dev/null @@ -1,48 +0,0 @@ -require "test_helper" - -class Account::SyncTest < ActiveSupport::TestCase - setup do - @account = accounts(:depository) - - @sync = Account::Sync.for(@account) - - @balance_syncer = mock("Account::Balance::Syncer") - @holding_syncer = mock("Account::Holding::Syncer") - end - - test "runs sync" do - Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once - Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).once - - @account.expects(:resolve_stale_issues).once - @balance_syncer.expects(:run).once - @holding_syncer.expects(:run).once - - assert_equal "pending", @sync.status - assert_nil @sync.last_ran_at - - @sync.run - - streams = capture_turbo_stream_broadcasts [ @account.family, :notifications ] - - assert_equal "completed", @sync.status - assert @sync.last_ran_at - - assert_equal "append", streams.first["action"] - assert_equal "remove", streams.second["action"] - assert_equal "append", streams.third["action"] - end - - test "handles sync errors" do - Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once - Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).never # error from balance sync halts entire sync - - @balance_syncer.expects(:run).raises(StandardError.new("test sync error")) - - @sync.run - - assert @sync.last_ran_at - assert_equal "failed", @sync.status - assert_equal "test sync error", @sync.error - end -end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 3da01860..15dc923f 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,34 +1,13 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase - include ActiveJob::TestHelper + include SyncableInterfaceTest setup do - @account = accounts(:depository) + @account = @syncable = accounts(:depository) @family = families(:dylan_family) end - test "can sync later" do - assert_enqueued_with(job: AccountSyncJob, args: [ @account, start_date: Date.current ]) do - @account.sync_later start_date: Date.current - end - end - - test "can sync" do - start_date = 10.days.ago.to_date - - mock_sync = mock("Account::Sync") - mock_sync.expects(:run).once - - Account::Sync.expects(:for).with(@account, start_date: start_date).returns(mock_sync).once - - @account.sync start_date: start_date - end - - test "needs sync if account has not synced today" do - assert @account.needs_sync? - end - test "groups accounts by type" do result = @family.accounts.by_group(period: Period.all) assets = result[:assets] @@ -47,7 +26,7 @@ class AccountTest < ActiveSupport::TestCase loans = liabilities.children.find { |group| group.name == "Loan" } other_liabilities = liabilities.children.find { |group| group.name == "OtherLiability" } - assert_equal 1, depositories.children.count + assert_equal 2, depositories.children.count assert_equal 1, properties.children.count assert_equal 1, vehicles.children.count assert_equal 1, investments.children.count diff --git a/test/models/family_test.rb b/test/models/family_test.rb index d56888ec..74376a7e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -3,9 +3,28 @@ require "csv" class FamilyTest < ActiveSupport::TestCase include Account::EntriesTestHelper + include SyncableInterfaceTest def setup - @family = families :empty + @family = families(:empty) + @syncable = families(:dylan_family) + end + + test "syncs plaid items and manual accounts" do + family_sync = syncs(:family) + + manual_accounts_count = @syncable.accounts.manual.count + items_count = @syncable.plaid_items.count + + Account.any_instance.expects(:sync_data) + .with(start_date: nil) + .times(manual_accounts_count) + + PlaidItem.any_instance.expects(:sync_data) + .with(start_date: nil) + .times(items_count) + + @syncable.sync_data(start_date: family_sync.start_date) end test "calculates assets" do @@ -48,30 +67,6 @@ class FamilyTest < ActiveSupport::TestCase assert_equal Money.new(50000, @family.currency), @family.net_worth end - test "needs sync if last family sync was before today" do - assert @family.needs_sync? - - @family.update! last_synced_at: Time.now - - assert_not @family.needs_sync? - end - - test "syncs active accounts" do - account = create_account(balance: 1000, accountable: CreditCard.new, is_active: false) - - Account.any_instance.expects(:sync_later).never - - @family.sync - - account.update! is_active: true - - Account.any_instance.expects(:needs_sync?).once.returns(true) - Account.any_instance.expects(:last_sync_date).once.returns(2.days.ago.to_date) - Account.any_instance.expects(:sync_later).with(start_date: 2.days.ago.to_date).once - - @family.sync - end - test "calculates snapshot" do asset = create_account(balance: 500, accountable: Depository.new) liability = create_account(balance: 100, accountable: CreditCard.new) diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb new file mode 100644 index 00000000..d689e855 --- /dev/null +++ b/test/models/plaid_item_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class PlaidItemTest < ActiveSupport::TestCase + include SyncableInterfaceTest + + setup do + @plaid_item = @syncable = plaid_items(:one) + end + + test "removes plaid item when destroyed" do + @plaid_provider = mock + + PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) + + @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once + + assert_difference "PlaidItem.count", -1 do + @plaid_item.destroy + end + end +end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb new file mode 100644 index 00000000..5fdf5898 --- /dev/null +++ b/test/models/sync_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class SyncTest < ActiveSupport::TestCase + setup do + @sync = syncs(:account) + @sync.update(status: "pending") + end + + test "runs successful sync" do + @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).once + + assert_equal "pending", @sync.status + + previously_ran_at = @sync.last_ran_at + + @sync.perform + + assert @sync.last_ran_at > previously_ran_at + assert_equal "completed", @sync.status + end + + test "handles sync errors" do + @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).raises(StandardError.new("test sync error")) + + assert_equal "pending", @sync.status + previously_ran_at = @sync.last_ran_at + + @sync.perform + + assert @sync.last_ran_at > previously_ran_at + assert_equal "failed", @sync.status + assert_equal "test sync error", @sync.error + end +end diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 87907323..9ce77814 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -4,6 +4,8 @@ class AccountsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + Family.any_instance.stubs(:get_link_token).returns("test-link-token") + visit root_url open_new_account_modal end @@ -67,7 +69,7 @@ class AccountsTest < ApplicationSystemTestCase assert_account_created("OtherLiability") end - test "can sync all acounts on accounts page" do + test "can sync all accounts on accounts page" do visit accounts_url assert_button "Sync all" end From 69f6d7f8eae8c799dd05846730e6658711232224 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 17:33:18 -0500 Subject: [PATCH 011/626] Enable consent for additional plaid products --- app/javascript/controllers/plaid_controller.js | 2 ++ app/models/provider/plaid.rb | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index a36f40af..14b0c182 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -19,6 +19,8 @@ export default class extends Controller { } handleSuccess(public_token, metadata) { + window.location.href = "/accounts"; + fetch("/plaid_items", { method: "POST", headers: { diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 50c32f4e..80f6a806 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -1,9 +1,9 @@ class Provider::Plaid attr_reader :client + MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze - PLAID_PRODUCTS = %w[transactions investments liabilities].freeze MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 class << self @@ -74,7 +74,8 @@ class Provider::Plaid request = Plaid::LinkTokenCreateRequest.new({ user: { client_user_id: user_id }, client_name: "Maybe Finance", - products: get_products(accountable_type), + products: [ get_primary_product(accountable_type) ], + additional_consented_products: get_additional_consented_products(accountable_type), country_codes: [ get_plaid_country_code(country) ], language: get_plaid_language(language), webhook: webhooks_url, @@ -198,17 +199,21 @@ class Provider::Plaid [ transactions, securities ] end - def get_products(accountable_type) + def get_primary_product(accountable_type) case accountable_type when "Investment" - %w[investments] + "investments" when "CreditCard", "Loan" - %w[liabilities] + "liabilities" else - %w[transactions] + "transactions" end end + def get_additional_consented_products(accountable_type) + MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ] + end + def get_plaid_country_code(country_code) PLAID_COUNTRY_CODES.include?(country_code) ? country_code : "US" end From 0af5faaa9f840133d25c58a1b972f0f591c5a705 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 18 Nov 2024 10:47:05 -0500 Subject: [PATCH 012/626] Make encryption config optional for self hosting users (#1476) * Fix redirect 404 bug * Make encryption optional for self-hosters * Fix test --- app/controllers/concerns/store_location.rb | 10 ++++++++++ app/models/plaid_item.rb | 5 ++++- config/application.rb | 5 +++++ config/environments/production.rb | 2 -- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/controllers/concerns/store_location.rb b/app/controllers/concerns/store_location.rb index 2bf87a64..e2e8d318 100644 --- a/app/controllers/concerns/store_location.rb +++ b/app/controllers/concerns/store_location.rb @@ -5,6 +5,8 @@ module StoreLocation helper_method :previous_path before_action :store_return_to after_action :clear_previous_path + + rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found end def previous_path @@ -12,6 +14,14 @@ module StoreLocation end private + def handle_not_found + if request.fullpath == session[:return_to] + session.delete(:return_to) + redirect_to fallback_path + else + head :not_found + end + end def store_return_to if params[:return_to].present? diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c2ca4cbc..d456285e 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,7 +1,10 @@ class PlaidItem < ApplicationRecord include Plaidable, Syncable - encrypts :access_token, deterministic: true + if Rails.application.credentials.active_record_encryption.present? + encrypts :access_token, deterministic: true + end + validates :name, :access_token, presence: true before_destroy :remove_plaid_item diff --git a/config/application.rb b/config/application.rb index 3615cb76..df4c0f36 100644 --- a/config/application.rb +++ b/config/application.rb @@ -30,5 +30,10 @@ module Maybe config.i18n.fallbacks = true config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry + + # Self hosters can optionally set their own encryption keys if they want to use ActiveRecord encryption. + if Rails.application.credentials.active_record_encryption.present? + config.active_record.encryption = Rails.application.credentials.active_record_encryption + end end end diff --git a/config/environments/production.rb b/config/environments/production.rb index c032dfed..ea7cd99d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -94,6 +94,4 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - config.active_record.encryption = Rails.application.credentials.active_record_encryption end From 91eedfbd1b3efe9e36709d379db6aba776572768 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:56:36 -0500 Subject: [PATCH 013/626] Bump plaid from 33.0.0 to 34.0.0 (#1475) Bumps [plaid](https://github.com/plaid/plaid-ruby) from 33.0.0 to 34.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/v33.0.0...v34.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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ac24d234..1b2d64fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -223,7 +223,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.7.2) + json (2.8.2) jwt (2.9.3) base64 language_server-protocol (3.17.0.3) @@ -253,7 +253,7 @@ GEM ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) - net-http (0.4.1) + net-http (0.5.0) uri net-imap (0.5.0) date @@ -286,7 +286,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) - plaid (33.0.0) + plaid (34.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) prism (1.2.0) @@ -447,7 +447,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) - uri (0.13.1) + uri (1.0.2) useragent (0.16.10) vcr (6.3.1) base64 From 951a29d92314597ef924868beae6a2431e7897d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:56:53 -0500 Subject: [PATCH 014/626] Bump pagy from 9.2.1 to 9.3.0 (#1474) Bumps [pagy](https://github.com/ddnexus/pagy) from 9.2.1 to 9.3.0. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/9.2.1...9.3.0) --- updated-dependencies: - dependency-name: pagy 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 1b2d64fc..0cb451e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.2.1) + pagy (9.3.0) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) From f15875560e2262b6efd7951a7a41ba5187898e72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:57:02 -0500 Subject: [PATCH 015/626] Bump faraday from 2.12.0 to 2.12.1 (#1473) Bumps [faraday](https://github.com/lostisland/faraday) from 2.12.0 to 2.12.1. - [Release notes](https://github.com/lostisland/faraday/releases) - [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md) - [Commits](https://github.com/lostisland/faraday/compare/v2.12.0...v2.12.1) --- updated-dependencies: - dependency-name: faraday 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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0cb451e9..da97c63b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,14 +155,14 @@ GEM tzinfo faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + faraday (2.12.1) + faraday-net_http (>= 2.0, < 3.5) json logger faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.3.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) faraday-retry (2.2.1) faraday (~> 2.0) ffi (1.17.0-aarch64-linux-gnu) From 8befb8a8b0c38d6fa245bebec3b9a8fbf73959c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:57:14 -0500 Subject: [PATCH 016/626] Bump aws-sdk-s3 from 1.170.0 to 1.171.0 (#1471) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.170.0 to 1.171.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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index da97c63b..3f3c84f1 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.1003.0) - aws-sdk-core (3.212.0) + aws-partitions (1.1009.0) + aws-sdk-core (3.213.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -92,7 +92,7 @@ GEM aws-sdk-kms (1.95.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.170.0) + aws-sdk-s3 (1.171.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 8b672c4062118c2ea30579b01fbb8cdb30e9d183 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:57:31 -0500 Subject: [PATCH 017/626] Bump stripe from 13.1.1 to 13.1.2 (#1472) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.1.1 to 13.1.2. - [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.1.1...v13.1.2) --- 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 3f3c84f1..89bb393f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -427,7 +427,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.1) - stripe (13.1.1) + stripe (13.1.2) tailwindcss-rails (3.0.0) railties (>= 7.0.0) tailwindcss-ruby From 9cc9f42bdc03541690eaa8ae2cecb38cb8af4139 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 18 Nov 2024 08:31:17 -0800 Subject: [PATCH 018/626] Allow custom column separator for CSV parsing in uploads controller (#1470) * Allow custom column separator for CSV parsing in uploads controller * Add column separator parameter for CSV uploads in tests --- app/controllers/import/uploads_controller.rb | 2 +- test/controllers/import/uploads_controller_test.rb | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index e89988ac..f3c65d6e 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -32,7 +32,7 @@ class Import::UploadsController < ApplicationController require "csv" begin - csv = CSV.parse(str || "", headers: true) + csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep]) return false if csv.headers.empty? return false if csv.count == 0 true diff --git a/test/controllers/import/uploads_controller_test.rb b/test/controllers/import/uploads_controller_test.rb index 75db3c4a..eb7f418b 100644 --- a/test/controllers/import/uploads_controller_test.rb +++ b/test/controllers/import/uploads_controller_test.rb @@ -14,7 +14,8 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "uploads valid csv by copy and pasting" do patch import_upload_url(@import), params: { import: { - raw_file_str: file_fixture("imports/valid.csv").read + raw_file_str: file_fixture("imports/valid.csv").read, + col_sep: "," } } @@ -25,7 +26,8 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "uploads valid csv by file" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/valid.csv") + csv_file: file_fixture_upload("imports/valid.csv"), + col_sep: "," } } @@ -36,7 +38,8 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "invalid csv cannot be uploaded" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/invalid.csv") + csv_file: file_fixture_upload("imports/invalid.csv"), + col_sep: "," } } From 6105f822b775d4796c90a35c249c757c9e6eff58 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 18 Nov 2024 11:44:41 -0500 Subject: [PATCH 019/626] Display chart dates in UTC --- .../controllers/time_series_chart_controller.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 428060ae..0a0c1bee 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -51,17 +51,12 @@ export default class extends Controller { _normalizeDataPoints() { this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ ...d, - date: this._parseDate(d.date), + date: new Date(d.date), value: d.value.amount ? +d.value.amount : +d.value, currency: d.value.currency, })); } - _parseDate(dateString) { - const [year, month, day] = dateString.split("-").map(Number); - return new Date(year, month - 1, day); - } - _rememberInitialContainerSize() { this._d3InitialContainerWidth = this._d3Container.node().clientWidth; this._d3InitialContainerHeight = this._d3Container.node().clientHeight; @@ -188,7 +183,7 @@ export default class extends Controller { this._normalDataPoints[this._normalDataPoints.length - 1].date, ]) .tickSize(0) - .tickFormat(d3.timeFormat("%d %b %Y")), + .tickFormat(d3.utcFormat("%d %b %Y")), ) .select(".domain") .remove(); @@ -367,7 +362,7 @@ export default class extends Controller { _tooltipTemplate(datum) { return `
- ${d3.timeFormat("%b %d, %Y")(datum.date)} + ${d3.utcFormat("%b %d, %Y")(datum.date)}
From 743e291d56d1914fb1b9aa6117f3e7827039a6cb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 18 Nov 2024 12:01:27 -0500 Subject: [PATCH 020/626] Fix tooltip trend color --- .../controllers/time_series_chart_controller.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 0a0c1bee..93f719fe 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -399,8 +399,14 @@ export default class extends Controller { _tooltipTrendColor(datum) { return { - up: tailwindColors.success, - down: tailwindColors.error, + up: + datum.trend.favorable_direction === "up" + ? tailwindColors.success + : tailwindColors.error, + down: + datum.trend.favorable_direction === "down" + ? tailwindColors.success + : tailwindColors.error, flat: tailwindColors.gray[500], }[datum.trend.direction]; } From fcb95207d700f800628265a3be1d2f7e63ccf4fe Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 18 Nov 2024 12:49:03 -0500 Subject: [PATCH 021/626] Limit transaction editing for crypto accounts --- app/views/account/entries/index.html.erb | 8 +++++--- app/views/accounts/show/_menu.html.erb | 8 +++++--- config/currencies.yml | 14 -------------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index e501a0cf..9e6d5d42 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -14,9 +14,11 @@ <%= tag.span t(".new_balance"), class: "text-sm" %> <% end %> - <%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %> - <%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %> - <%= tag.span t(".new_transaction"), class: "text-sm" %> + <% unless @account.crypto? %> + <%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %> + <%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %> + <%= tag.span t(".new_transaction"), class: "text-sm" %> + <% end %> <% end %>
diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 1c601032..5d9efa44 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -19,12 +19,14 @@ <%= t(".edit") %> <% end %> - <%= link_to new_import_path, + <% unless account.crypto? %> + <%= link_to new_import_path, data: { turbo_frame: :modal }, class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> + <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> - <%= t(".import") %> + <%= t(".import") %> + <% end %> <% end %> <%= button_to account_path(account), diff --git a/config/currencies.yml b/config/currencies.yml index 97f10d42..07d80c39 100644 --- a/config/currencies.yml +++ b/config/currencies.yml @@ -866,20 +866,6 @@ zmw: delimiter: "," default_format: "%u%n" default_precision: 2 -bch: - name: Bitcoin Cash - priority: 100 - iso_code: BCH - iso_numeric: "" - html_code: "₿" - symbol: "₿" - minor_unit: Satoshi - minor_unit_conversion: 100000000 - smallest_denomination: 1 - separator: "." - delimiter: "," - default_format: "%n %u" - default_precision: 8 btc: name: Bitcoin priority: 100 From 81d604f3d4c8a9b1b61bc30cf1d4829aaa0fac26 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 18 Nov 2024 15:50:47 -0500 Subject: [PATCH 022/626] Fix transfers and form currencies (#1477) --- app/controllers/account/trades_controller.rb | 5 ++++- app/controllers/account/transfers_controller.rb | 3 +-- app/controllers/account/valuations_controller.rb | 5 ++++- app/models/account/entry_builder.rb | 1 + app/models/account/transaction_builder.rb | 5 +++-- app/models/account/transfer.rb | 14 +++++++++++--- app/views/account/trades/_form.html.erb | 5 +++-- app/views/account/transfers/_form.html.erb | 6 +++--- app/views/account/valuations/_form.html.erb | 2 +- app/views/shared/_money_field.html.erb | 6 ++++-- test/controllers/account/trades_controller_test.rb | 8 ++++++-- 11 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index e0897c9f..f57c3089 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -5,7 +5,10 @@ class Account::TradesController < ApplicationController before_action :set_entry, only: :update def new - @entry = @account.entries.account_trades.new(entryable_attributes: {}) + @entry = @account.entries.account_trades.new( + currency: @account.currency, + entryable_attributes: {} + ) end def index diff --git a/app/controllers/account/transfers_controller.rb b/app/controllers/account/transfers_controller.rb index cdcca52d..0aaac5c2 100644 --- a/app/controllers/account/transfers_controller.rb +++ b/app/controllers/account/transfers_controller.rb @@ -16,8 +16,7 @@ class Account::TransfersController < ApplicationController @transfer = Account::Transfer.build_from_accounts from_account, to_account, \ date: transfer_params[:date], - amount: transfer_params[:amount].to_d, - currency: transfer_params[:currency] + amount: transfer_params[:amount].to_d if @transfer.save @transfer.entries.each(&:sync_account_later) diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb index 577dfbf4..35b83b90 100644 --- a/app/controllers/account/valuations_controller.rb +++ b/app/controllers/account/valuations_controller.rb @@ -4,7 +4,10 @@ class Account::ValuationsController < ApplicationController before_action :set_account def new - @entry = @account.entries.account_valuations.new(entryable_attributes: {}) + @entry = @account.entries.account_valuations.new( + currency: @account.currency, + entryable_attributes: {} + ) end def create diff --git a/app/models/account/entry_builder.rb b/app/models/account/entry_builder.rb index 0cfa3189..189acfdd 100644 --- a/app/models/account/entry_builder.rb +++ b/app/models/account/entry_builder.rb @@ -40,6 +40,7 @@ class Account::EntryBuilder date: date, amount: amount, account: account, + currency: currency, transfer_account_id: transfer_account_id end end diff --git a/app/models/account/transaction_builder.rb b/app/models/account/transaction_builder.rb index b6968443..6c87d6a4 100644 --- a/app/models/account/transaction_builder.rb +++ b/app/models/account/transaction_builder.rb @@ -3,7 +3,7 @@ class Account::TransactionBuilder TYPES = %w[income expense interest transfer_in transfer_out].freeze - attr_accessor :type, :amount, :date, :account, :transfer_account_id + attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id validates :type, :amount, :date, presence: true validates :type, inclusion: { in: TYPES } @@ -45,8 +45,9 @@ class Account::TransactionBuilder def build_entry(account_id, amount, marked_as_transfer: false) Account::Entry.new \ account_id: account_id, + name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest", amount: amount, - currency: account.currency, + currency: currency, date: date, marked_as_transfer: marked_as_transfer, entryable: Account::Transaction.new diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index ec464003..b919c4b7 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -49,7 +49,7 @@ class Account::Transfer < ApplicationRecord end class << self - def build_from_accounts(from_account, to_account, date:, amount:, currency:) + def build_from_accounts(from_account, to_account, date:, amount:) outflow = from_account.entries.build \ amount: amount.abs, currency: from_account.currency, @@ -58,9 +58,17 @@ class Account::Transfer < ApplicationRecord marked_as_transfer: true, entryable: Account::Transaction.new + # Attempt to convert the amount to the to_account's currency. If the conversion fails, + # use the original amount. + converted_amount = begin + Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency) + rescue Money::ConversionError + Money.new(amount.abs, from_account.currency) + end + inflow = to_account.entries.build \ - amount: amount.abs * -1, - currency: from_account.currency, + amount: converted_amount.amount * -1, + currency: converted_amount.currency.iso_code, date: date, name: "Transfer from #{from_account.name}", marked_as_transfer: true, diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index 41180a20..a3330927 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -1,6 +1,7 @@ <%# locals: (entry:) %> <%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" }, + model: entry, scope: :account_entry, url: account_trades_path(entry.account) do |form| %>
@@ -15,7 +16,7 @@ <%= form.date_field :date, label: true, value: Date.today %>
- <%= form.money_field :price, label: t(".price"), disable_currency: true %> + <%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %>
diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 0ce7566a..2d9e5741 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -1,4 +1,4 @@ -<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> +<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top", controller: "transfer-form" } do |f| %> <% if transfer.errors.present? %>
<%= lucide_icon "circle-alert", class: "w-5 h-5" %> @@ -28,8 +28,8 @@
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> - <%= f.money_field :amount, label: t(".amount"), required: true, hide_currency: true %> - <%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %> + <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> + <%= f.date_field :date, value: transfer.date || Date.today, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index a84d2ae5..0ceef3c3 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -6,7 +6,7 @@ data: { turbo: false } do |form| %>
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %> - <%= form.money_field :amount, label: t(".amount"), required: true, default_currency: Current.family.currency %> + <%= form.money_field :amount, label: t(".amount"), required: true %>
<%= form.submit t(".submit") %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 0bbbc0a7..4a3f8001 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -1,6 +1,8 @@ <%# locals: (form:, amount_method:, currency_method:, **options) %> -<% currency_value = if form.object && form.object.respond_to?(currency_method) +<% currency_value = if options[:currency_value_override].present? + options[:currency_value_override] + elsif form.object && form.object.respond_to?(currency_method) form.object.public_send(currency_method) end currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %> @@ -44,7 +46,7 @@
<%= form.select currency_method, currencies_for_select.map(&:iso_code), - { inline: true }, + { inline: true, selected: currency_value }, { class: "w-fit pr-5 disabled:text-gray-400 form-field__input", disabled: options[:disable_currency], diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index 43f20720..eaa4f4c5 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -27,6 +27,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest type: "transfer_in", date: Date.current, amount: 10, + currency: "USD", transfer_account_id: from_account.id } } @@ -46,6 +47,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest type: "transfer_out", date: Date.current, amount: 10, + currency: "USD", transfer_account_id: to_account.id } } @@ -62,7 +64,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest account_entry: { type: "transfer_out", date: Date.current, - amount: 10 + amount: 10, + currency: "USD" } } end @@ -80,7 +83,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest account_entry: { type: "interest", date: Date.current, - amount: 10 + amount: 10, + currency: "USD" } } end From d1b506d16c80f6aaeb5adc1b1d25b4e27c080615 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 19 Nov 2024 16:23:21 -0500 Subject: [PATCH 023/626] Pass date as UTC for graphs --- app/javascript/controllers/time_series_chart_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 93f719fe..626ec745 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -51,7 +51,7 @@ export default class extends Controller { _normalizeDataPoints() { this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ ...d, - date: new Date(d.date), + date: new Date(`${d.date}T00:00:00Z`), value: d.value.amount ? +d.value.amount : +d.value, currency: d.value.currency, })); From e641cfccd47ab64d7d4b21415d8a8dc5138232e5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 20 Nov 2024 11:01:52 -0500 Subject: [PATCH 024/626] Add post-sync hook (#1479) --- app/models/concerns/syncable.rb | 4 ++++ app/models/family.rb | 4 ++++ app/models/plaid_item.rb | 4 ++++ app/models/sync.rb | 23 +++++++++-------------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index ec6abb2e..042eb6b1 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -22,6 +22,10 @@ module Syncable raise NotImplementedError, "Subclasses must implement the `sync_data` method" end + def post_sync + # no-op, syncable can optionally provide implementation + end + def sync_error latest_sync.error end diff --git a/app/models/family.rb b/app/models/family.rb index 0e9226f8..673df231 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -32,6 +32,10 @@ class Family < ApplicationRecord end end + def post_sync + broadcast_refresh + end + def syncing? super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?) end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index d456285e..5492edaf 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -42,6 +42,10 @@ class PlaidItem < ApplicationRecord end end + def post_sync + family.broadcast_refresh + end + def destroy_later update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) diff --git a/app/models/sync.rb b/app/models/sync.rb index c0a8b53c..84e078da 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -8,32 +8,27 @@ class Sync < ApplicationRecord def perform start! - syncable.sync_data(start_date: start_date) - - complete! - rescue StandardError => error - fail! error - raise error if Rails.env.development? + begin + syncable.sync_data(start_date: start_date) + complete! + rescue StandardError => error + fail! error + raise error if Rails.env.development? + ensure + syncable.post_sync + end end private - def family - syncable.is_a?(Family) ? syncable : syncable.family - end - def start! update! status: :syncing end def complete! update! status: :completed, last_ran_at: Time.current - - family.broadcast_refresh end def fail!(error) update! status: :failed, error: error.message, last_ran_at: Time.current - - family.broadcast_refresh end end From 6996a225ba6a1f2320f259cd83d63bb79681d34d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 20 Nov 2024 16:46:06 -0500 Subject: [PATCH 025/626] Add post-sync UI stream updates (#1482) * Add post-sync UI stream updates * Fix stream channel id --- .gitignore | 1 + Procfile.dev | 4 ++-- app/models/account.rb | 4 ++++ app/models/account/balance/syncer.rb | 2 +- app/models/concerns/accountable.rb | 11 +++++++++++ app/models/investment.rb | 11 +++++++++++ app/views/accounts/show/_chart.html.erb | 4 ++-- app/views/accounts/show/_header.html.erb | 2 +- app/views/investments/_chart.html.erb | 4 ++-- 9 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index dc09d53e..b75bf5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Ignore bundler config. /.bundle +/vendor/bundle # Ignore all environment files (except templates). /.env* diff --git a/Procfile.dev b/Procfile.dev index 18f6d0df..f3cfa172 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ -web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 -css: bin/rails tailwindcss:watch +web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 +css: bundle exec bin/rails tailwindcss:watch worker: bundle exec good_job start diff --git a/app/models/account.rb b/app/models/account.rb index fcc9dc25..e9a6160f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -99,6 +99,10 @@ class Account < ApplicationRecord Holding::Syncer.new(self, start_date: start_date).run end + def post_sync + accountable.post_sync + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb index d0e4546e..24a43f8b 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/account/balance/syncer.rb @@ -46,6 +46,6 @@ class Account::Balance::Syncer end def is_partial_sync? - sync_start_date == provided_start_date && sync_start_date < Date.current + sync_start_date == provided_start_date end end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index a7a10284..84515374 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -33,4 +33,15 @@ module Accountable rescue Money::ConversionError TimeSeries.new([]) end + + def post_sync + broadcast_remove_to(account, target: "syncing-notification") + + broadcast_replace_to( + account, + target: "chart_account_#{account.id}", + partial: "accounts/show/chart", + locals: { account: account } + ) + end end diff --git a/app/models/investment.rb b/app/models/investment.rb index e9df9a4f..ced62765 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -54,4 +54,15 @@ class Investment < ApplicationRecord def icon "line-chart" end + + def post_sync + broadcast_remove_to(account, target: "syncing-notification") + + broadcast_replace_to( + account, + target: "chart_account_#{account.id}", + partial: account.plaid_account_id.present? ? "investments/chart" : "accounts/show/chart", + locals: { account: account } + ) + end end diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index c3762c62..6b825b48 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,11 +1,11 @@ -<%# locals: (account:, title: nil, tooltip: nil) %> +<%# locals: (account:, title: nil, tooltip: nil, **args) %> <% period = Period.from_param(params[:period]) %> <% series = account.series(period: period) %> <% trend = series.trend %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %> -
+
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 1a2b39a5..5abd0873 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -21,7 +21,7 @@
<% unless account.plaid_account_id.present? %> - <%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> <% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb index 9f88954e..ceb7f32b 100644 --- a/app/views/investments/_chart.html.erb +++ b/app/views/investments/_chart.html.erb @@ -1,10 +1,10 @@ -<%# locals: (account:) %> +<%# locals: (account:, **args) %> <% period = Period.from_param(params[:period]) %> <% series = account.series(period: period) %> <% trend = series.trend %> -
+
From 242eb5cea1f216dcba76ebda185f99e6ecb01af4 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 22 Nov 2024 11:38:41 -0300 Subject: [PATCH 026/626] Calculates balance based on previous transaction on the same date (#1483) --- app/models/account/entry.rb | 10 +++++++++- test/models/account/entry_test.rb | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 09d19618..2e77b19c 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -15,6 +15,7 @@ class Account::Entry < ApplicationRecord validates :date, comparison: { greater_than: -> { min_supported_date } } scope :chronological, -> { order(:date, :created_at) } + scope :not_account_valuations, -> { where.not(entryable_type: "Account::Valuation") } scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } scope :without_transfers, -> { where(marked_as_transfer: false) } scope :with_converted_amount, ->(currency) { @@ -54,6 +55,13 @@ class Account::Entry < ApplicationRecord account.balances.find_by(date: date - 1)&.balance || 0 end + def prior_entry_balance + entries_on_entry_date + .not_account_valuations + .last + &.balance_after_entry || 0 + end + def balance_after_entry if account_valuation? Money.new(amount, currency) @@ -75,7 +83,7 @@ class Account::Entry < ApplicationRecord def trend TimeSeries::Trend.new( current: balance_after_entry, - previous: Money.new(prior_balance, currency), + previous: Money.new(prior_entry_balance, currency), favorable_direction: account.favorable_direction ) end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index e99f8db0..b5711d29 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -110,4 +110,15 @@ class Account::EntryTest < ActiveSupport::TestCase assert_equal Money.new(100), transaction.balance_after_entry end + + test "prior_entry_balance returns last transaction entry balance" do + family = families(:empty) + account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new + + new_valuation = create_valuation(account: account, amount: 1) + transaction = create_transaction(date: new_valuation.date, account: account, amount: -100) + + + assert_equal Money.new(100), transaction.prior_entry_balance + end end From c309c8abf89469fc2b91d5ef54fdc86d35965510 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 22 Nov 2024 10:41:16 -0500 Subject: [PATCH 027/626] Safely call liability object when syncing --- app/models/plaid_item.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 5492edaf..5abcff8f 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -107,9 +107,9 @@ class PlaidItem < ApplicationRecord if fetched_liabilities transaction do internal_plaid_accounts.each do |internal_plaid_account| - credit = fetched_liabilities.credit.find { |l| l.account_id == internal_plaid_account.plaid_id } - mortgage = fetched_liabilities.mortgage.find { |l| l.account_id == internal_plaid_account.plaid_id } - student = fetched_liabilities.student.find { |l| l.account_id == internal_plaid_account.plaid_id } + credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id } + mortgage = fetched_liabilities.mortgage&.find { |l| l.account_id == internal_plaid_account.plaid_id } + student = fetched_liabilities.student&.find { |l| l.account_id == internal_plaid_account.plaid_id } internal_plaid_account.sync_credit_data!(credit) if credit internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage From c8302a6d49d1b1466ebf0b9169f114aa5d75a0fe Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 22 Nov 2024 14:22:52 -0600 Subject: [PATCH 028/626] Let super admins toggle admin bar --- app/helpers/application_helper.rb | 8 ++++++++ app/views/layouts/application.html.erb | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 59ddebf9..3871a7d9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -158,4 +158,12 @@ module ApplicationHelper .map { |_currency, money| format_money(money) } .join(separator) end + + def show_super_admin_bar? + if params[:admin].present? + cookies.permanent[:admin] = params[:admin] + end + + cookies[:admin] == "true" + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 305987c3..e6f026d3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -28,7 +28,7 @@ - <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %> + <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %> <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
From 571fc4db75b49b26e2dd12ae54a63c5de904b1a0 Mon Sep 17 00:00:00 2001 From: Jestin Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Sat, 23 Nov 2024 01:57:07 +0530 Subject: [PATCH 029/626] Replaced Native Scrollbars with Tailwind Scrollbars on Windows (#1493) * feat: scrollbar styling for windows browsers * fix: lint --- app/assets/stylesheets/application.tailwind.css | 16 ++++++++++++++++ app/controllers/application_controller.rb | 14 ++++++++++++++ app/views/layouts/application.html.erb | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 6d989d27..d9ea7d48 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -162,4 +162,20 @@ .scrollbar::-webkit-scrollbar-thumb:hover { background: #a6a6a6; } +} + +/* Custom scrollbar implementation for Windows browsers */ +.windows { + ::-webkit-scrollbar { + width: 4px; + } + + ::-webkit-scrollbar-thumb { + background: #d6d6d6; + border-radius: 10px; + } + + ::-webkit-scrollbar-thumb:hover { + background: #a6a6a6; + } } \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e9788920..3643c9c7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,6 +4,8 @@ class ApplicationController < ActionController::Base helper_method :require_upgrade?, :subscription_pending? + before_action :detect_os + private def require_upgrade? return false if self_hosted? @@ -24,4 +26,16 @@ class ApplicationController < ActionController::Base "with_sidebar" end + + def detect_os + user_agent = request.user_agent + @os = case user_agent + when /Windows/i then "windows" + when /Macintosh/i then "mac" + when /Linux/i then "linux" + when /Android/i then "android" + when /iPhone|iPad/i then "ios" + else "" + end + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e6f026d3..8c8abc9c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Maybe" %> From 1c2f07505316c34be4ccb199160ef7df6659a0eb Mon Sep 17 00:00:00 2001 From: Arsen Shkrumelyak Date: Fri, 22 Nov 2024 20:28:10 +0000 Subject: [PATCH 030/626] Fix bug: Loan % doesn't allow exact rate (#1492) * Fix bug: Loan % doesn't allow exact rate Fixes #1487 Change the step of the interest rate field to 0.005. It's unlikely that we'll see interest rates in smaller increments. * step 0.005 * migration for loan interest rates precision * add new line --- app/views/loans/_form.html.erb | 2 +- ...20241122183828_change_loan_interest_rate_precision.rb.rb | 5 +++++ db/schema.rb | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20241122183828_change_loan_interest_rate_precision.rb.rb diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index bfadda90..bceee968 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -10,7 +10,7 @@ label: t("loans.form.interest_rate"), placeholder: t("loans.form.interest_rate_placeholder"), min: 0, - step: 0.01 %> + step: 0.005 %> <%= loan_form.select :rate_type, [["Fixed", "fixed"], ["Variable", "variable"], ["Adjustable", "adjustable"]], { label: t("loans.form.rate_type") } %> diff --git a/db/migrate/20241122183828_change_loan_interest_rate_precision.rb.rb b/db/migrate/20241122183828_change_loan_interest_rate_precision.rb.rb new file mode 100644 index 00000000..45a5d59e --- /dev/null +++ b/db/migrate/20241122183828_change_loan_interest_rate_precision.rb.rb @@ -0,0 +1,5 @@ +class ChangeLoanInterestRatePrecision < ActiveRecord::Migration[7.2] + def change + change_column :loans, :interest_rate, :decimal, precision: 10, scale: 3 + end +end diff --git a/db/schema.rb b/db/schema.rb index fa9dea5a..e662e3be 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: 2024_11_14_164118) do +ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -107,7 +107,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_14_164118) do t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false @@ -439,7 +439,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_14_164118) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "rate_type" - t.decimal "interest_rate", precision: 10, scale: 2 + t.decimal "interest_rate", precision: 10, scale: 3 t.integer "term_months" end From c1034e6edf1b06d1e6e2db8467024557bcd7d888 Mon Sep 17 00:00:00 2001 From: Arsen Shkrumelyak Date: Fri, 22 Nov 2024 20:28:36 +0000 Subject: [PATCH 031/626] Update `index` method in `AccountsController` to fetch all accounts (#1491) * Fetch all manual accounts, regardless of their active status * Fetch all Plaid items, regardless of their active status --- app/controllers/accounts_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 93a47388..56c29d26 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.manual.active.alphabetically - @plaid_items = Current.family.plaid_items.active.ordered + @manual_accounts = Current.family.accounts.manual.alphabetically + @plaid_items = Current.family.plaid_items.ordered end def summary From 03e92e63a57b22637beeea3365f6b9d594f4be13 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 25 Nov 2024 09:32:07 -0500 Subject: [PATCH 032/626] Attempt to sync transactions regardless of main item type --- app/models/plaid_item.rb | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 5abcff8f..881cba17 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -51,14 +51,6 @@ class PlaidItem < ApplicationRecord DestroyJob.perform_later(self) end - def has_investment_accounts? - available_products.include?("investments") || billed_products.include?("investments") - end - - def has_liability_accounts? - available_products.include?("liabilities") || billed_products.include?("liabilities") - end - private def fetch_and_load_plaid_data item = plaid_provider.get_item(access_token).item @@ -72,7 +64,7 @@ class PlaidItem < ApplicationRecord internal_plaid_account end - fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) unless has_investment_accounts? + fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) if fetched_transactions transaction do @@ -88,7 +80,7 @@ class PlaidItem < ApplicationRecord end end - fetched_investments = safe_fetch_plaid_data(:get_item_investments) if has_investment_accounts? + fetched_investments = safe_fetch_plaid_data(:get_item_investments) if fetched_investments transaction do @@ -102,7 +94,7 @@ class PlaidItem < ApplicationRecord end end - fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) if has_liability_accounts? + fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) if fetched_liabilities transaction do From a4adfed82ba8a6afb05288e64261ab247f8f2b51 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 25 Nov 2024 09:48:21 -0500 Subject: [PATCH 033/626] Disable Plaid i18n until we support full i18n --- app/models/family.rb | 2 -- app/models/provider/plaid.rb | 17 +++-------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/app/models/family.rb b/app/models/family.rb index 673df231..1f8cc561 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -45,8 +45,6 @@ class Family < ApplicationRecord plaid_provider.get_link_token( user_id: id, - country: country, - language: locale, webhooks_url: webhooks_url, redirect_url: redirect_url, accountable_type: accountable_type diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 80f6a806..7fd18291 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -2,8 +2,6 @@ class Provider::Plaid attr_reader :client MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze - PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze - PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 class << self @@ -70,14 +68,14 @@ class Provider::Plaid @client = self.class.client end - def get_link_token(user_id:, country:, language: "en", webhooks_url:, redirect_url:, accountable_type: nil) + 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_plaid_country_code(country) ], - language: get_plaid_language(language), + country_codes: [ "US" ], + language: "en", webhook: webhooks_url, redirect_uri: redirect_url, transactions: { days_requested: MAX_HISTORY_DAYS } @@ -213,13 +211,4 @@ class Provider::Plaid def get_additional_consented_products(accountable_type) MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ] end - - def get_plaid_country_code(country_code) - PLAID_COUNTRY_CODES.include?(country_code) ? country_code : "US" - end - - def get_plaid_language(locale = "en") - language = locale.split("-").first - PLAID_LANGUAGES.include?(language) ? language : "en" - end end From 25e9bd4c608ff594b679c1744226e15bdf0cc701 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:55:58 -0500 Subject: [PATCH 034/626] Bump stripe from 13.1.2 to 13.2.0 (#1501) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.1.2 to 13.2.0. - [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.1.2...v13.2.0) --- updated-dependencies: - dependency-name: stripe 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 89bb393f..0a494ec5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -427,7 +427,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.1) - stripe (13.1.2) + stripe (13.2.0) tailwindcss-rails (3.0.0) railties (>= 7.0.0) tailwindcss-ruby From 84f069448a3bb22e9e238f8da49ca2dde22ed202 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:56:07 -0500 Subject: [PATCH 035/626] Bump mocha from 2.5.0 to 2.6.0 (#1500) Bumps [mocha](https://github.com/freerange/mocha) from 2.5.0 to 2.6.0. - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v2.5.0...v2.6.0) --- updated-dependencies: - dependency-name: mocha 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0a494ec5..71e7bb90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -249,7 +249,7 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.1) - mocha (2.5.0) + mocha (2.6.0) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) From 57a87f28503d86093e69eacbcbadbbbe3daa17bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:56:16 -0500 Subject: [PATCH 036/626] Bump pagy from 9.3.0 to 9.3.1 (#1499) Bumps [pagy](https://github.com/ddnexus/pagy) from 9.3.0 to 9.3.1. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/9.3.0...9.3.1) --- updated-dependencies: - dependency-name: pagy 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 71e7bb90..5f9d91f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.3.0) + pagy (9.3.1) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) From 6c503e4d269ddc26c83871c9f7063517841851a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:08:11 -0500 Subject: [PATCH 037/626] Bump puma from 6.4.3 to 6.5.0 (#1498) Bumps [puma](https://github.com/puma/puma) from 6.4.3 to 6.5.0. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/master/History.md) - [Commits](https://github.com/puma/puma/compare/v6.4.3...v6.5.0) --- updated-dependencies: - dependency-name: puma 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 5f9d91f5..c6fdd470 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -298,7 +298,7 @@ GEM psych (5.1.2) stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) From fc603a1733b2bc3942cd5d53089bc97debeb26c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:10:12 -0500 Subject: [PATCH 038/626] Bump good_job from 4.4.2 to 4.5.0 (#1497) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.4.2 to 4.5.0. - [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.4.2...v4.5.0) --- 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 | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c6fdd470..de1690d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,7 +100,7 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) - benchmark (0.3.0) + benchmark (0.4.0) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -176,7 +176,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.4.2) + good_job (4.5.0) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) @@ -248,7 +248,7 @@ GEM matrix (0.4.2) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.1) + minitest (5.25.2) mocha (2.6.0) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) @@ -295,7 +295,7 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) puma (6.5.0) @@ -307,7 +307,7 @@ GEM rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rackup (2.2.0) + rackup (2.2.1) rack (>= 3) rails (7.2.2) actioncable (= 7.2.2) @@ -351,11 +351,11 @@ GEM ffi (~> 1.0) rbs (3.6.1) logger - rdoc (6.7.0) + rdoc (6.8.1) psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.9.2) - reline (0.5.10) + reline (0.5.11) io-console (~> 0.5) rexml (3.3.9) rubocop (1.67.0) @@ -402,7 +402,7 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - securerandom (0.3.1) + securerandom (0.3.2) selenium-webdriver (4.26.0) base64 (~> 0.2) logger (~> 1.4) @@ -426,7 +426,7 @@ GEM stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.1) + stringio (3.1.2) stripe (13.2.0) tailwindcss-rails (3.0.0) railties (>= 7.0.0) @@ -440,7 +440,7 @@ GEM terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) thor (1.3.2) - timeout (0.4.1) + timeout (0.4.2) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) From b5666ad7a9292ab2d7d11a41f56e56064c9d2b5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:10:20 -0500 Subject: [PATCH 039/626] Bump aws-sdk-s3 from 1.171.0 to 1.173.0 (#1496) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.171.0 to 1.173.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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index de1690d6..483cba48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,16 +83,16 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1009.0) + aws-partitions (1.1013.0) aws-sdk-core (3.213.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.95.0) + aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.171.0) + aws-sdk-s3 (1.173.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From de9ffa7ca0ef2d6920e51556690d49de6888a519 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:10:31 -0500 Subject: [PATCH 040/626] Bump ruby-lsp-rails from 0.3.26 to 0.3.27 (#1495) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.26 to 0.3.27. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.26...v0.3.27) --- 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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 483cba48..075902ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -386,13 +386,13 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.21.3) + ruby-lsp (0.22.1) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.26) - ruby-lsp (>= 0.21.2, < 0.22.0) + ruby-lsp-rails (0.3.27) + ruby-lsp (>= 0.22.0, < 0.23.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) ffi (~> 1.12) @@ -422,7 +422,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11645) + sorbet-runtime (0.5.11663) stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) From 570a0c7ff69f67bd0c4acdfabadcdff8b2ffaf93 Mon Sep 17 00:00:00 2001 From: Evlos <4tyle8@gmail.com> Date: Mon, 25 Nov 2024 23:17:00 +0800 Subject: [PATCH 041/626] [#] base_url on synth.rb (#1490) --- app/models/provider/synth.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 0e367f1e..8247ab34 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -174,7 +174,7 @@ class Provider::Synth SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true def base_url - "https://api.synthfinance.com" + ENV["SYNTH_URL"] || "https://api.synthfinance.com" end def app_name From 955f211fe0011976d642cbeabc83e6ce02efbcdb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 25 Nov 2024 13:28:31 -0500 Subject: [PATCH 042/626] Allow 0 qty for Plaid imported trades --- app/models/account/trade.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index 17e0b070..3ab2241e 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -5,7 +5,7 @@ class Account::Trade < ApplicationRecord belongs_to :security - validates :qty, presence: true, numericality: { other_than: 0 } + validates :qty, presence: true validates :price, :currency, presence: true class << self From a9b61a655b79664c5be869985c6581b7f0bae8e5 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 26 Nov 2024 07:45:00 -0600 Subject: [PATCH 043/626] Synth error handling (#1502) * Synth error handling * Revert "Synth error handling" This reverts commit fd6a0a12b49eee23ad8d7473ef15c12cf425e0c4. * Simplify overage messaging --- app/models/family.rb | 4 ++++ app/views/pages/dashboard.html.erb | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/app/models/family.rb b/app/models/family.rb index 1f8cc561..b4bb2d4c 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -153,6 +153,10 @@ class Family < ApplicationRecord self.class.synth_provider&.usage end + def synth_overage? + self.class.synth_provider && self.class.synth_provider.usage.utilization >= 1 + end + def subscribed? stripe_subscription_status == "active" end diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 666cf509..a2e5c66b 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,4 +1,9 @@
+ <% if self_hosted? && Current.family&.synth_overage? %> + + <% end %>

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

From 76f271400674ef2071f8fc2afb55332c2f1bf4f8 Mon Sep 17 00:00:00 2001 From: Nikhil Badyal <59223300+nikhilbadyal@users.noreply.github.com> Date: Wed, 27 Nov 2024 05:03:26 +0530 Subject: [PATCH 044/626] Updated usage check threshold to 100pc instead of 1 (#1504) --- app/models/family.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/family.rb b/app/models/family.rb index b4bb2d4c..a618220c 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -154,7 +154,7 @@ class Family < ApplicationRecord end def synth_overage? - self.class.synth_provider && self.class.synth_provider.usage.utilization >= 1 + self.class.synth_provider && self.class.synth_provider.usage.utilization >= 100 end def subscribed? From c3248cd796b3052e72502e7b18ee75d9467770c1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 27 Nov 2024 16:01:50 -0500 Subject: [PATCH 045/626] Improve account transaction, trade, and valuation editing and sync experience (#1506) * Consolidate entry controller logic * Transaction builder * Update trades controller to use new params * Load account charts in turbo frames, fix PG overflow * Consolidate tests * Tests passing * Remove unused code * Add client side trade form validations --- app/controllers/account/cashes_controller.rb | 9 +- app/controllers/account/entries_controller.rb | 45 +------ .../account/holdings_controller.rb | 20 +-- app/controllers/account/trades_controller.rb | 86 ++++-------- .../transaction_categories_controller.rb | 22 +++ .../account/transactions_controller.rb | 97 ++++++-------- .../account/valuations_controller.rb | 37 +---- app/controllers/accounts_controller.rb | 5 + .../concerns/entryable_resource.rb | 126 ++++++++++++++++++ app/controllers/securities_controller.rb | 17 ++- app/controllers/transactions_controller.rb | 91 +------------ app/helpers/application_helper.rb | 4 +- app/javascript/application.js | 4 + app/javascript/controllers/application.js | 4 +- .../controllers/modal_controller.js | 10 +- .../controllers/trade_form_controller.js | 70 +--------- app/models/account.rb | 2 +- app/models/account/entry.rb | 6 +- app/models/account/entry_builder.rb | 46 ------- app/models/account/trade.rb | 3 +- app/models/account/trade_builder.rb | 116 ++++++++++++---- app/models/account/transaction_builder.rb | 64 --------- app/models/account/transfer.rb | 4 + app/models/concerns/accountable.rb | 3 +- app/models/family.rb | 1 + app/models/investment.rb | 2 +- app/models/provider/synth.rb | 4 +- app/models/security.rb | 22 ++- app/models/security/synth_combobox_option.rb | 14 -- app/views/account/entries/_entry.html.erb | 4 +- .../account/entries/_selection_bar.html.erb | 2 +- app/views/account/entries/index.html.erb | 6 +- app/views/account/holdings/_holding.html.erb | 2 +- app/views/account/holdings/show.html.erb | 4 +- app/views/account/trades/_form.html.erb | 58 +++++--- app/views/account/trades/_header.html.erb | 68 ++++++++++ .../account/trades/_security.turbo_stream.erb | 11 -- .../account/trades/_selection_bar.html.erb | 2 +- app/views/account/trades/_trade.html.erb | 12 +- .../trades/securities.turbo_stream.erb | 2 - app/views/account/trades/show.html.erb | 94 ++++--------- .../{ => account}/transactions/_form.html.erb | 20 ++- .../account/transactions/_header.html.erb | 23 ++++ .../transactions/_selection_bar.html.erb | 6 +- .../transactions/_transaction.html.erb | 6 +- .../transactions/bulk_edit.html.erb | 4 +- app/views/account/transactions/index.html.erb | 2 +- .../{ => account}/transactions/new.html.erb | 2 +- app/views/account/transactions/show.html.erb | 98 +++++--------- app/views/account/transfers/_form.html.erb | 4 +- .../transfers/_transfer_toggle.html.erb | 2 +- app/views/account/valuations/_form.html.erb | 11 +- app/views/account/valuations/_header.html.erb | 19 +++ .../account/valuations/_valuation.html.erb | 4 +- app/views/account/valuations/show.html.erb | 30 +---- app/views/accounts/_chart_loader.html.erb | 5 + app/views/accounts/chart.html.erb | 32 +++++ app/views/accounts/show/_activity.html.erb | 2 +- app/views/accounts/show/_chart.html.erb | 25 +--- app/views/accounts/show/_tab.html.erb | 1 + app/views/categories/_menu.html.erb | 7 +- app/views/category/dropdowns/_row.html.erb | 13 +- app/views/category/dropdowns/show.html.erb | 2 +- app/views/investments/_cash_tab.html.erb | 2 +- app/views/investments/_chart.html.erb | 4 - app/views/investments/_holdings_tab.html.erb | 2 +- app/views/layouts/application.html.erb | 2 +- .../_combobox_security.turbo_stream.erb | 11 ++ app/views/securities/index.turbo_stream.erb | 2 + app/views/shared/_drawer.html.erb | 7 +- app/views/shared/_form_errors.html.erb | 6 + app/views/shared/_notification.html.erb | 5 +- app/views/shared/_syncing_notice.html.erb | 7 + app/views/transactions/_header.html.erb | 2 +- app/views/transactions/index.html.erb | 2 +- app/views/transactions/rules.html.erb | 16 --- config/brakeman.ignore | 25 +++- config/locales/views/account/entries/en.yml | 2 + config/locales/views/account/holdings/en.yml | 2 + config/locales/views/account/trades/en.yml | 20 ++- .../locales/views/account/transactions/en.yml | 41 +++++- .../locales/views/account/valuations/en.yml | 5 +- config/locales/views/accounts/en.yml | 3 +- config/locales/views/layout/en.yml | 2 - config/locales/views/shared/en.yml | 2 + config/locales/views/transactions/en.yml | 37 ----- config/routes.rb | 56 +++++--- ...20241126211249_add_logo_url_to_security.rb | 5 + db/schema.rb | 3 +- .../account/entries_controller_test.rb | 60 +-------- .../account/holdings_controller_test.rb | 10 +- .../account/trades_controller_test.rb | 68 +++++++--- .../account/transactions_controller_test.rb | 113 +++++++++++++--- .../account/valuations_controller_test.rb | 71 +++++----- .../transactions_controller_test.rb | 119 ----------------- .../entryable_resource_interface_test.rb | 25 ++++ test/system/trades_test.rb | 8 +- 97 files changed, 1103 insertions(+), 1159 deletions(-) create mode 100644 app/controllers/account/transaction_categories_controller.rb create mode 100644 app/controllers/concerns/entryable_resource.rb delete mode 100644 app/models/account/entry_builder.rb delete mode 100644 app/models/account/transaction_builder.rb create mode 100644 app/views/account/trades/_header.html.erb delete mode 100644 app/views/account/trades/_security.turbo_stream.erb delete mode 100644 app/views/account/trades/securities.turbo_stream.erb rename app/views/{ => account}/transactions/_form.html.erb (65%) create mode 100644 app/views/account/transactions/_header.html.erb rename app/views/{ => account}/transactions/bulk_edit.html.erb (91%) rename app/views/{ => account}/transactions/new.html.erb (51%) create mode 100644 app/views/account/valuations/_header.html.erb create mode 100644 app/views/accounts/_chart_loader.html.erb create mode 100644 app/views/accounts/chart.html.erb create mode 100644 app/views/securities/_combobox_security.turbo_stream.erb create mode 100644 app/views/securities/index.turbo_stream.erb create mode 100644 app/views/shared/_form_errors.html.erb create mode 100644 app/views/shared/_syncing_notice.html.erb delete mode 100644 app/views/transactions/rules.html.erb create mode 100644 db/migrate/20241126211249_add_logo_url_to_security.rb create mode 100644 test/interfaces/entryable_resource_interface_test.rb diff --git a/app/controllers/account/cashes_controller.rb b/app/controllers/account/cashes_controller.rb index 6afa3241..f94582ce 100644 --- a/app/controllers/account/cashes_controller.rb +++ b/app/controllers/account/cashes_controller.rb @@ -1,14 +1,7 @@ class Account::CashesController < ApplicationController layout :with_sidebar - before_action :set_account - def index + @account = Current.family.accounts.find(params[:account_id]) end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end end diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb index d78cb62c..b36cdbc6 100644 --- a/app/controllers/account/entries_controller.rb +++ b/app/controllers/account/entries_controller.rb @@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController layout :with_sidebar before_action :set_account - before_action :set_entry, only: %i[edit update show destroy] def index @q = search_params - @pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10") - end - - def edit - render entryable_view_path(:edit) - end - - def update - prev_amount = @entry.amount - prev_date = @entry.date - - @entry.update!(entry_params) - @entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date - - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) } - end - end - - def show - render entryable_view_path(:show) - end - - def destroy - @entry.destroy! - @entry.sync_account_later - redirect_to account_url(@entry.account), notice: t(".success") + @pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10") end private - - def entryable_view_path(action) - @entry.entryable_type.underscore.pluralize + "/" + action.to_s - end - def set_account @account = Current.family.accounts.find(params[:account_id]) end - def set_entry - @entry = @account.entries.find(params[:id]) - end - - def entry_params - params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes) + def entries_scope + scope = Current.family.entries + scope = scope.where(account: @account) if @account + scope end def search_params diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index af0d3e6a..c316b854 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -1,11 +1,12 @@ class Account::HoldingsController < ApplicationController layout :with_sidebar - before_action :set_account before_action :set_holding, only: %i[show destroy] def index - @holdings = @account.holdings.current + @account = Current.family.accounts.find(params[:account_id]) + @holdings = Current.family.holdings.current + @holdings = @holdings.where(account: @account) if @account end def show @@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController def destroy @holding.destroy_holding_and_entries! - redirect_back_or_to account_holdings_path(@account) + + flash[:notice] = t(".success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@holding.account) } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) } + end end private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - def set_holding - @holding = @account.holdings.current.find(params[:id]) + @holding = Current.family.holdings.current.find(params[:id]) end end diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index f57c3089..6ace6538 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -1,69 +1,37 @@ class Account::TradesController < ApplicationController - layout :with_sidebar + include EntryableResource - before_action :set_account - before_action :set_entry, only: :update - - def new - @entry = @account.entries.account_trades.new( - currency: @account.currency, - entryable_attributes: {} - ) - end - - def index - @entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction]) - end - - def create - @builder = Account::EntryBuilder.new(entry_params) - - if entry = @builder.save - entry.sync_account_later - redirect_to @account, notice: t(".success") - else - flash[:alert] = t(".failure") - redirect_back_or_to @account - end - end - - def update - @entry.update!(entry_params) - - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) } - end - end - - def securities - query = params[:q] - return render json: [] if query.blank? || query.length < 2 || query.length > 100 - - @securities = Security::SynthComboboxOption.find_in_synth(query) - end + permitted_entryable_attributes :id, :qty, :price private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) + def build_entry + Account::TradeBuilder.new(create_entry_params) end - def set_entry - @entry = @account.entries.find(params[:id]) + def create_entry_params + params.require(:account_entry).permit( + :account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id + ).tap do |params| + account_id = params.delete(:account_id) + params[:account] = Current.family.accounts.find(account_id) + end end - def entry_params - params.require(:account_entry) - .permit( - :type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type, - entryable_attributes: [ - :id, - :qty, - :ticker, - :price - ] - ) - .merge(account: @account) + def update_entry_params + return entry_params unless entry_params[:entryable_attributes].present? + + update_params = entry_params + update_params = update_params.merge(entryable_type: "Account::Trade") + + qty = update_params[:entryable_attributes][:qty] + price = update_params[:entryable_attributes][:price] + + if qty.present? && price.present? + qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d + update_params[:entryable_attributes][:qty] = qty + update_params[:amount] = qty * price.to_d + end + + update_params.except(:nature) end end diff --git a/app/controllers/account/transaction_categories_controller.rb b/app/controllers/account/transaction_categories_controller.rb new file mode 100644 index 00000000..5920a0b3 --- /dev/null +++ b/app/controllers/account/transaction_categories_controller.rb @@ -0,0 +1,22 @@ +class Account::TransactionCategoriesController < ApplicationController + def update + @entry = Current.family.entries.account_transactions.find(params[:transaction_id]) + @entry.update!(entry_params) + + respond_to do |format| + format.html { redirect_back_or_to account_transaction_path(@entry) } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "category_menu_account_transaction_#{@entry.account_transaction_id}", + partial: "categories/menu", + locals: { transaction: @entry.account_transaction } + ) + end + end + end + + private + def entry_params + params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) + end +end diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb index 26c9e62d..6784aac6 100644 --- a/app/controllers/account/transactions_controller.rb +++ b/app/controllers/account/transactions_controller.rb @@ -1,74 +1,55 @@ class Account::TransactionsController < ApplicationController - layout :with_sidebar + include EntryableResource - before_action :set_account - before_action :set_entry, only: :update + permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] } - def index - @pagy, @entries = pagy( - @account.entries.account_transactions.reverse_chronological, - limit: params[:per_page] || "10" - ) + def bulk_delete + destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) + destroyed.map(&:account).uniq.each(&:sync_later) + redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) end - def update - prev_amount = @entry.amount - prev_date = @entry.date + def bulk_edit + end - @entry.update!(entry_params.except(:origin)) - @entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date + def bulk_update + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream do - render turbo_stream: turbo_stream.replace( - @entry, - partial: "account/entries/entry", - locals: entry_locals.merge(entry: @entry) - ) - end - end + redirect_back_or_to transactions_url, notice: t(".success", count: updated) + end + + def mark_transfers + Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .mark_transfers! + + redirect_back_or_to transactions_url, notice: t(".success") + end + + def unmark_transfers + Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .update_all marked_as_transfer: false + + redirect_back_or_to transactions_url, notice: t(".success") end private - def set_account - @account = Current.family.accounts.find(params[:account_id]) + def bulk_delete_params + params.require(:bulk_delete).permit(entry_ids: []) end - def set_entry - @entry = @account.entries.find(params[:id]) + def bulk_update_params + params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: []) end - def entry_locals - { - selectable: entry_params[:origin].present?, - show_balance: entry_params[:origin] == "account", - origin: entry_params[:origin] - } - end - - def entry_params - params.require(:account_entry) - .permit( - :name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin, - entryable_attributes: [ - :id, - :category_id, - :merchant_id, - { tag_ids: [] } - ] - ).tap do |permitted_params| - nature = permitted_params.delete(:nature) - - if permitted_params[:amount] - amount_value = permitted_params[:amount].to_d - - if nature == "income" - amount_value *= -1 - end - - permitted_params[:amount] = amount_value - end - end + def search_params + params.fetch(:q, {}) + .permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: []) end end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb index 35b83b90..08f566f3 100644 --- a/app/controllers/account/valuations_controller.rb +++ b/app/controllers/account/valuations_controller.rb @@ -1,38 +1,3 @@ class Account::ValuationsController < ApplicationController - layout :with_sidebar - - before_action :set_account - - def new - @entry = @account.entries.account_valuations.new( - currency: @account.currency, - entryable_attributes: {} - ) - end - - def create - @entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {})) - - if @entry.save - @entry.sync_account_later - redirect_back_or_to account_valuations_path(@account), notice: t(".success") - else - flash[:alert] = @entry.errors.full_messages.to_sentence - redirect_to @account - end - end - - def index - @entries = @account.entries.account_valuations.reverse_chronological - end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - - def entry_params - params.require(:account_entry).permit(:name, :date, :amount, :currency) - end + include EntryableResource end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 56c29d26..8d0c27c9 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -31,6 +31,11 @@ class AccountsController < ApplicationController redirect_to account_path(@account) end + def chart + @account = Current.family.accounts.find(params[:id]) + render layout: "application" + end + def sync_all unless Current.family.syncing? Current.family.sync_later diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb new file mode 100644 index 00000000..84aac1d4 --- /dev/null +++ b/app/controllers/concerns/entryable_resource.rb @@ -0,0 +1,126 @@ +module EntryableResource + extend ActiveSupport::Concern + + included do + layout :with_sidebar + before_action :set_entry, only: %i[show update destroy] + end + + class_methods do + def permitted_entryable_attributes(*attrs) + @permitted_entryable_attributes = attrs if attrs.any? + @permitted_entryable_attributes ||= [ :id ] + end + end + + def show + end + + def new + account = Current.family.accounts.find_by(id: params[:account_id]) + + @entry = Current.family.entries.new( + account: account, + currency: account ? account.currency : Current.family.currency, + entryable: entryable_type.new + ) + end + + def create + @entry = build_entry + + if @entry.save + @entry.sync_account_later + + flash[:notice] = t("account.entries.create.success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + + redirect_target_url = request.referer || account_path(@entry.account) + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(update_entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "header_account_entry_#{@entry.id}", + partial: "#{entryable_type.name.underscore.pluralize}/header", + locals: { entry: @entry } + ) + end + end + else + render :show, status: :unprocessable_entity + end + end + + def destroy + account = @entry.account + @entry.destroy! + @entry.sync_account_later + + flash[:notice] = t("account.entries.destroy.success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(account) } + + redirect_target_url = request.referer || account_path(@entry.account) + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } + end + end + + private + def entryable_type + permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade] + klass = params[:entryable_type] || "Account::#{controller_name.classify}" + klass.constantize if permitted_entryable_types.include?(klass) + end + + def set_entry + @entry = Current.family.entries.find(params[:id]) + end + + def build_entry + Current.family.entries.new(create_entry_params) + end + + def update_entry_params + prepared_entry_params + end + + def create_entry_params + prepared_entry_params.merge({ + entryable_type: entryable_type.name, + entryable_attributes: entry_params[:entryable_attributes] || {} + }) + end + + def prepared_entry_params + default_params = entry_params.except(:nature) + default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present? + + if entry_params[:nature].present? && entry_params[:amount].present? + signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d + default_params = default_params.merge(amount: signed_amount) + end + + default_params + end + + def entry_params + params.require(:account_entry).permit( + :account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, + entryable_attributes: self.class.permitted_entryable_attributes + ) + end +end diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 24356118..4a3c65c4 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -1,5 +1,18 @@ class SecuritiesController < ApplicationController - def import - SecuritiesImportJob.perform_later(params[:exchange_mic]) + def index + query = params[:q] + return render json: [] if query.blank? || query.length < 2 || query.length > 100 + + @securities = Security.search({ + search: query, + country: country_code_filter + }) end + + private + def country_code_filter + filter = params[:country_code] + filter = "#{filter},US" unless filter == "US" + filter + end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index b1733585..acceab79 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -13,94 +13,13 @@ class TransactionsController < ApplicationController } end - def new - @entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e| - if params[:account_id] - e.account = Current.family.accounts.find(params[:account_id]) - e.currency = e.account.currency - else - e.currency = Current.family.currency - end - end - end - - def create - @entry = Current.family - .accounts - .find(params[:account_entry][:account_id]) - .entries - .create!(transaction_entry_params.merge(amount: amount)) - - @entry.sync_account_later - redirect_back_or_to @entry.account, notice: t(".success") - end - - def bulk_delete - destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) - destroyed.map(&:account).uniq.each(&:sync_later) - redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) - end - - def bulk_edit - end - - def bulk_update - updated = Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .bulk_update!(bulk_update_params) - - redirect_back_or_to transactions_url, notice: t(".success", count: updated) - end - - def mark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .mark_transfers! - - redirect_back_or_to transactions_url, notice: t(".success") - end - - def unmark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .update_all marked_as_transfer: false - - redirect_back_or_to transactions_url, notice: t(".success") - end - private - - def amount - if nature.income? - transaction_entry_params[:amount].to_d * -1 - else - transaction_entry_params[:amount].to_d - end - end - - def nature - params[:account_entry][:nature].to_s.inquiry - end - - def bulk_delete_params - params.require(:bulk_delete).permit(entry_ids: []) - end - - def bulk_update_params - params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: []) - end - def search_params params.fetch(:q, {}) - .permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: []) - end - - def transaction_entry_params - params.require(:account_entry) - .permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ]) - .with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {}) + .permit( + :start_date, :end_date, :search, :amount, + :amount_operator, accounts: [], account_ids: [], + categories: [], merchants: [], types: [], tags: [] + ) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3871a7d9..8bf3cf28 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -62,9 +62,9 @@ module ApplicationHelper #
Content here
# <% end %> # - def drawer(&block) + def drawer(reload_on_close: false, &block) content = capture &block - render partial: "shared/drawer", locals: { content: content } + render partial: "shared/drawer", locals: { content:, reload_on_close: } end def disclosure(title, &block) diff --git a/app/javascript/application.js b/app/javascript/application.js index 874eae81..12751637 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,3 +1,7 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails"; import "controllers"; + +Turbo.StreamActions.redirect = function () { + Turbo.visit(this.target); +}; diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index 4b55996d..f898dcad 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -6,7 +6,7 @@ const application = Application.start(); application.debug = false; window.Stimulus = application; -Turbo.setConfirmMethod((message) => { +Turbo.config.forms.confirm = (message) => { const dialog = document.getElementById("turbo-confirm"); try { @@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => { { once: true }, ); }); -}); +}; export { application }; diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index a988dbb8..8c9d6c50 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="modal" export default class extends Controller { + static values = { + reloadOnClose: { type: Boolean, default: false }, + }; + connect() { if (this.element.open) return; this.element.showModal(); @@ -10,11 +14,15 @@ export default class extends Controller { // Hide the dialog when the user clicks outside of it clickOutside(e) { if (e.target === this.element) { - this.element.close(); + this.close(); } } close() { this.element.close(); + + if (this.reloadOnCloseValue) { + window.location.reload(); + } } } diff --git a/app/javascript/controllers/trade_form_controller.js b/app/javascript/controllers/trade_form_controller.js index cd435d55..bff84364 100644 --- a/app/javascript/controllers/trade_form_controller.js +++ b/app/javascript/controllers/trade_form_controller.js @@ -1,71 +1,11 @@ import { Controller } from "@hotwired/stimulus"; -const TRADE_TYPES = { - BUY: "buy", - SELL: "sell", - TRANSFER_IN: "transfer_in", - TRANSFER_OUT: "transfer_out", - INTEREST: "interest", -}; - -const FIELD_VISIBILITY = { - [TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true }, - [TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true }, - [TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true }, - [TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true }, - [TRADE_TYPES.INTEREST]: { amount: true }, -}; - // Connects to data-controller="trade-form" export default class extends Controller { - static targets = [ - "typeInput", - "tickerInput", - "amountInput", - "transferAccountInput", - "qtyInput", - "priceInput", - ]; - - connect() { - this.handleTypeChange = this.handleTypeChange.bind(this); - this.typeInputTarget.addEventListener("change", this.handleTypeChange); - this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY); - } - - disconnect() { - this.typeInputTarget.removeEventListener("change", this.handleTypeChange); - } - - handleTypeChange(event) { - this.updateFields(event.target.value); - } - - updateFields(type) { - const visibleFields = FIELD_VISIBILITY[type] || {}; - - Object.entries(this.fieldTargets).forEach(([field, target]) => { - const isVisible = visibleFields[field] || false; - - // Update visibility - target.hidden = !isVisible; - - // Update required status based on visibility - if (isVisible) { - target.setAttribute("required", ""); - } else { - target.removeAttribute("required"); - } - }); - } - - get fieldTargets() { - return { - ticker: this.tickerInputTarget, - amount: this.amountInputTarget, - transferAccount: this.transferAccountInputTarget, - qty: this.qtyInputTarget, - price: this.priceInputTarget, - }; + // Reloads the page with a new type without closing the modal + async changeType(event) { + const url = new URL(event.params.url, window.location.origin); + url.searchParams.set(event.params.key, event.target.value); + Turbo.visit(url, { frame: "modal" }); } } diff --git a/app/models/account.rb b/app/models/account.rb index e9a6160f..50fa6f56 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,7 +12,7 @@ class Account < ApplicationRecord has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" - has_many :holdings, dependent: :destroy + has_many :holdings, dependent: :destroy, class_name: "Account::Holding" has_many :balances, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 2e77b19c..2addf3be 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord } def sync_account_later - if destroyed? - sync_start_date = previous_entry&.date + sync_start_date = if destroyed? + previous_entry&.date else - sync_start_date = [ date_previously_was, date ].compact.min + [ date_previously_was, date ].compact.min end account.sync_later(start_date: sync_start_date) diff --git a/app/models/account/entry_builder.rb b/app/models/account/entry_builder.rb deleted file mode 100644 index 189acfdd..00000000 --- a/app/models/account/entry_builder.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Account::EntryBuilder - include ActiveModel::Model - - TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze - - attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id - - validates :type, inclusion: { in: TYPES } - - def save - if valid? - create_builder.save - end - end - - private - - def create_builder - case type - when "buy", "sell" - create_trade_builder - else - create_transaction_builder - end - end - - def create_trade_builder - Account::TradeBuilder.new \ - type: type, - date: date, - qty: qty, - ticker: ticker, - price: price, - account: account - end - - def create_transaction_builder - Account::TransactionBuilder.new \ - type: type, - date: date, - amount: amount, - account: account, - currency: currency, - transfer_account_id: transfer_account_id - end -end diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index 3ab2241e..b8ebd7b8 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord def name prefix = sell? ? "Sell " : "Buy " - generated = prefix + "#{qty.abs} shares of #{security.ticker}" - entry.name || generated + prefix + "#{qty.abs} shares of #{security.ticker}" end def unrealized_gain_loss diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index ec252897..dd6b966c 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -1,33 +1,103 @@ -class Account::TradeBuilder < Account::EntryBuilder +class Account::TradeBuilder include ActiveModel::Model - TYPES = %w[buy sell].freeze - - attr_accessor :type, :qty, :price, :ticker, :date, :account - - validates :type, :qty, :price, :ticker, :date, presence: true - validates :price, numericality: { greater_than: 0 } - validates :type, inclusion: { in: TYPES } + attr_accessor :account, :date, :amount, :currency, :qty, + :price, :ticker, :type, :transfer_account_id def save - if valid? - create_entry - end + buildable.save + end + + def errors + buildable.errors + end + + def sync_account_later + buildable.sync_account_later end private + def buildable + case type + when "buy", "sell" + build_trade + when "deposit", "withdrawal" + build_transfer + when "interest" + build_interest + else + raise "Unknown trade type: #{type}" + end + end - def create_entry - account.entries.account_trades.create! \ + def build_trade + account.entries.new( date: date, - amount: amount, - currency: account.currency, + amount: signed_amount, + currency: currency, entryable: Account::Trade.new( - security: security, qty: signed_qty, - price: price.to_d, - currency: account.currency + price: price, + currency: currency, + security: security ) + ) + end + + def build_transfer + transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present? + + if transfer_account + from_account = type == "withdrawal" ? account : transfer_account + to_account = type == "withdrawal" ? transfer_account : account + + Account::Transfer.build_from_accounts( + from_account, + to_account, + date: date, + amount: signed_amount + ) + else + account.entries.build( + name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}", + date: date, + amount: signed_amount, + currency: currency, + marked_as_transfer: true, + entryable: Account::Transaction.new + ) + end + end + + def build_interest + account.entries.build( + name: "Interest payment", + date: date, + amount: signed_amount, + currency: currency, + entryable: Account::Transaction.new + ) + end + + def signed_qty + return nil unless type.in?([ "buy", "sell" ]) + + type == "sell" ? -qty.to_d : qty.to_d + end + + def signed_amount + case type + when "buy", "sell" + signed_qty * price.to_d + when "deposit", "withdrawal" + type == "deposit" ? -amount.to_d : amount.to_d + when "interest" + amount.to_d * -1 + end + end + + def family + account.family end def security @@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder security end - - def amount - price.to_d * signed_qty - end - - def signed_qty - _qty = qty.to_d - _qty = _qty * -1 if type == "sell" - _qty - end end diff --git a/app/models/account/transaction_builder.rb b/app/models/account/transaction_builder.rb deleted file mode 100644 index 6c87d6a4..00000000 --- a/app/models/account/transaction_builder.rb +++ /dev/null @@ -1,64 +0,0 @@ -class Account::TransactionBuilder - include ActiveModel::Model - - TYPES = %w[income expense interest transfer_in transfer_out].freeze - - attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id - - validates :type, :amount, :date, presence: true - validates :type, inclusion: { in: TYPES } - - def save - if valid? - transfer? ? create_transfer : create_transaction - end - end - - private - - def transfer? - %w[transfer_in transfer_out].include?(type) - end - - def create_transfer - return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank? - - from_account_id = type == "transfer_in" ? transfer_account_id : account.id - to_account_id = type == "transfer_in" ? account.id : transfer_account_id - - outflow = create_unlinked_transfer(from_account_id, signed_amount.abs) - inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1) - - Account::Transfer.create! entries: [ outflow, inflow ] - - inflow - end - - def create_unlinked_transfer(account_id, amount) - build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!) - end - - def create_transaction - build_entry(account.id, signed_amount).tap(&:save!) - end - - def build_entry(account_id, amount, marked_as_transfer: false) - Account::Entry.new \ - account_id: account_id, - name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest", - amount: amount, - currency: currency, - date: date, - marked_as_transfer: marked_as_transfer, - entryable: Account::Transaction.new - end - - def signed_amount - case type - when "expense", "transfer_out" - amount.to_d - else - amount.to_d * -1 - end - end -end diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index b919c4b7..174576e8 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord end end + def sync_account_later + entries.each(&:sync_account_later) + end + class << self def build_from_accounts(from_account, to_account, date:, amount:) outflow = from_account.entries.build \ diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 84515374..6c93a8f8 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -35,8 +35,9 @@ module Accountable end def post_sync - broadcast_remove_to(account, target: "syncing-notification") + broadcast_remove_to(account.family, target: "syncing-notice") + # Broadcast a simple replace event that the controller can handle broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/family.rb b/app/models/family.rb index a618220c..e32c0d78 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -15,6 +15,7 @@ class Family < ApplicationRecord has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy has_many :issues, through: :accounts + has_many :holdings, through: :accounts has_many :plaid_items, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } diff --git a/app/models/investment.rb b/app/models/investment.rb index ced62765..90519da3 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -56,7 +56,7 @@ class Investment < ApplicationRecord end def post_sync - broadcast_remove_to(account, target: "syncing-notification") + broadcast_remove_to(account, target: "syncing-notice") broadcast_replace_to( account, diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 8247ab34..5044f00a 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -134,12 +134,12 @@ class Provider::Synth securities = parsed.dig("data").map do |security| { - symbol: security.dig("symbol"), + ticker: security.dig("symbol"), name: security.dig("name"), logo_url: security.dig("logo_url"), exchange_acronym: security.dig("exchange", "acronym"), exchange_mic: security.dig("exchange", "mic_code"), - exchange_country_code: security.dig("exchange", "country_code") + country_code: security.dig("exchange", "country_code") } end diff --git a/app/models/security.rb b/app/models/security.rb index 732599ce..d2ce6387 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -8,17 +8,33 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false } + class << self + def search(query) + security_prices_provider.search_securities( + query: query[:search], + dataset: "limited", + country_code: query[:country] + ).securities.map { |attrs| new(**attrs) } + end + end + def current_price @current_price ||= Security::Price.find_price(security: self, date: Date.current) return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end - def to_combobox_display - "#{ticker} (#{exchange_acronym})" + def to_combobox_option + SynthComboboxOption.new( + symbol: ticker, + name: name, + logo_url: logo_url, + exchange_acronym: exchange_acronym, + exchange_mic: exchange_mic, + exchange_country_code: country_code + ) end - private def upcase_ticker diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index efd81db6..d3b4437d 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -1,22 +1,8 @@ class Security::SynthComboboxOption include ActiveModel::Model - include Providable attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code - class << self - def find_in_synth(query) - country = Current.family.country - country = "#{country},US" unless country == "US" - - security_prices_provider.search_securities( - query:, - dataset: "limited", - country_code: country - ).securities.map { |attrs| new(**attrs) } - end - end - def id "#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value end diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb index 4b839916..9bfe063a 100644 --- a/app/views/account/entries/_entry.html.erb +++ b/app/views/account/entries/_entry.html.erb @@ -1,5 +1,5 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <%= turbo_frame_tag dom_id(entry) do %> - <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %> + <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %> <% end %> diff --git a/app/views/account/entries/_selection_bar.html.erb b/app/views/account/entries/_selection_bar.html.erb index f4f7208e..3bbf39d1 100644 --- a/app/views/account/entries/_selection_bar.html.erb +++ b/app/views/account/entries/_selection_bar.html.erb @@ -6,7 +6,7 @@
- <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 9e6d5d42..c66d3767 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -9,13 +9,13 @@ <%= tag.span t(".new") %>
diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index a3330927..fbb288df 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -1,35 +1,51 @@ <%# locals: (entry:) %> -<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" }, - model: entry, - scope: :account_entry, - url: account_trades_path(entry.account) do |form| %> +<% type = params[:type] || "buy" %> + +<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %> + + <%= form.hidden_field :account_id %> +
+ <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
- <%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %> -
+ <%= form.select :type, [ + ["Buy", "buy"], + ["Sell", "sell"], + ["Deposit", "deposit"], + ["Withdrawal", "withdrawal"], + ["Interest", "interest"] + ], + { label: t(".type"), selected: type }, + { data: { + action: "trade-form#changeType", + trade_form_url_param: new_account_trade_path(account_id: entry.account_id), + trade_form_key_param: "type", + }} %> + + <% if %w[buy sell].include?(type) %>
- <%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %> + <%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
-
+ <% end %> - <%= form.date_field :date, label: true, value: Date.today %> + <%= form.date_field :date, label: true, value: Date.today, required: true %> - + <% unless %w[buy sell].include?(type) %> + <%= form.money_field :amount, label: t(".amount"), required: true %> + <% end %> - + <% end %> -
- <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %> -
- -
- <%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %> -
+ <% if %w[buy sell].include?(type) %> + <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %> + <%= form.money_field :price, label: t(".price"), required: true %> + <% end %>
<%= form.submit t(".submit") %> diff --git a/app/views/account/trades/_header.html.erb b/app/views/account/trades/_header.html.erb new file mode 100644 index 00000000..7ccadfa4 --- /dev/null +++ b/app/views/account/trades/_header.html.erb @@ -0,0 +1,68 @@ +<%# locals: (entry:) %> + +
+ <%= tag.header class: "mb-4 space-y-1" do %> + + <%= entry.amount.negative? ? t(".sell") : t(".buy") %> + + +
+

+ + <%= format_money entry.amount_money %> + + + + <%= entry.currency %> + +

+
+ + + <%= I18n.l(entry.date, format: :long) %> + + <% end %> + + <% trade = entry.account_trade %> + +
+ <%= disclosure t(".overview") do %> +
+
+
+
<%= t(".symbol_label") %>
+
<%= trade.security.ticker %>
+
+ + <% if trade.buy? %> +
+
<%= t(".purchase_qty_label") %>
+
<%= trade.qty.abs %>
+
+ +
+
<%= t(".purchase_price_label") %>
+
<%= format_money trade.price_money %>
+
+ <% end %> + + <% if trade.security.current_price.present? %> +
+
<%= t(".current_market_price_label") %>
+
<%= format_money trade.security.current_price %>
+
+ <% end %> + + <% if trade.buy? && trade.unrealized_gain_loss.present? %> +
+
<%= t(".total_return_label") %>
+
+ <%= render "shared/trend_change", trend: trade.unrealized_gain_loss %> +
+
+ <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/account/trades/_security.turbo_stream.erb b/app/views/account/trades/_security.turbo_stream.erb deleted file mode 100644 index 34bdebd3..00000000 --- a/app/views/account/trades/_security.turbo_stream.erb +++ /dev/null @@ -1,11 +0,0 @@ -
- <%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> -
- - <%= security.name.presence || security.symbol %> - - - <%= "#{security.symbol} (#{security.exchange_acronym})" %> - -
-
diff --git a/app/views/account/trades/_selection_bar.html.erb b/app/views/account/trades/_selection_bar.html.erb index f4f7208e..3bbf39d1 100644 --- a/app/views/account/trades/_selection_bar.html.erb +++ b/app/views/account/trades/_selection_bar.html.erb @@ -6,7 +6,7 @@
- <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index 29cd40f4..bd91d147 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -1,8 +1,8 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% trade, account = entry.account_trade, entry.account %> -
+
text-sm font-medium p-4">
<% if selectable %> <%= check_box_tag dom_id(entry, "selection"), @@ -16,12 +16,12 @@ <%= trade.name.first.upcase %>
-
+
<% if entry.new_record? %> <%= content_tag :p, trade.name %> <% else %> <%= link_to trade.name, - account_entry_path(account, entry), + account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -31,7 +31,9 @@
- <%= tag.span format_money(entry.amount_money) %> + <%= content_tag :p, + format_money(-entry.amount_money), + class: ["text-green-600": entry.amount.negative?] %>
diff --git a/app/views/account/trades/securities.turbo_stream.erb b/app/views/account/trades/securities.turbo_stream.erb deleted file mode 100644 index a3225939..00000000 --- a/app/views/account/trades/securities.turbo_stream.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= async_combobox_options @securities, - render_in: { partial: "account/trades/security" } %> diff --git a/app/views/account/trades/show.html.erb b/app/views/account/trades/show.html.erb index 0f07e9ad..1a1d8cf6 100644 --- a/app/views/account/trades/show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -1,83 +1,37 @@ -<% entry, trade, account = @entry, @entry.account_trade, @entry.account %> +<%= drawer(reload_on_close: true) do %> + <%= render "account/trades/header", entry: @entry %> -<%= drawer do %> -
-
-

- - <%= format_money -entry.amount_money %> - - - - <%= entry.currency %> - -

-
- - - <%= I18n.l(entry.date, format: :long) %> - -
+ <% trade = @entry.account_trade %>
- - <%= disclosure t(".overview") do %> -
-
-
-
<%= t(".symbol_label") %>
-
<%= trade.security.ticker %>
-
- - <% if trade.buy? %> -
-
<%= t(".purchase_qty_label") %>
-
<%= trade.qty.abs %>
-
- -
-
<%= t(".purchase_price_label") %>
-
<%= format_money trade.price_money %>
-
- <% end %> - - <% if trade.security.current_price.present? %> -
-
<%= t(".current_market_price_label") %>
-
<%= format_money trade.security.current_price %>
-
- <% end %> - - <% if trade.buy? && trade.unrealized_gain_loss.present? %> -
-
<%= t(".total_return_label") %>
-
- <%= render "shared/trend_change", trend: trade.unrealized_gain_loss %> -
-
- <% end %> -
-
- <% end %> - <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.date_field :date, label: t(".date_label"), - max: Date.current, + max: Date.today, "data-auto-submit-form-target": "auto" %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.number_field :qty, +
+ <%= f.select :nature, + [["Buy", "outflow"], ["Sell", "inflow"]], + { container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" }, + { data: { "auto-submit-form-target": "auto" } } %> + + <%= f.fields_for :entryable do |ef| %> + <%= ef.number_field :qty, label: t(".quantity_label"), step: "any", + value: trade.qty.abs, "data-auto-submit-form-target": "auto" %> + <% end %> +
+ <%= f.fields_for :entryable do |ef| %> <%= ef.money_field :price, label: t(".cost_per_share_label"), disable_currency: true, @@ -91,8 +45,8 @@ <%= disclosure t(".additional") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_area :notes, @@ -108,8 +62,8 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "p-3", data: { controller: "auto-submit-form" } do |f| %>
@@ -136,11 +90,11 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(@entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", - data: { turbo_confirm: true, turbo_frame: "_top" } %> + data: { turbo_confirm: true } %>
<% end %> diff --git a/app/views/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb similarity index 65% rename from app/views/transactions/_form.html.erb rename to app/views/account/transactions/_form.html.erb index dad6157b..10c09d30 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -1,8 +1,13 @@ -<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> +<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %> + + <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
- <%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %> - <%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %> + <%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %> + <%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %> <%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %> <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %> <%= tag.span t(".transfer") %> @@ -12,9 +17,14 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> - <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + + <% if @entry.account_id %> + <%= f.hidden_field :account_id %> + <% else %> + <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <% end %> + <%= f.money_field :amount, label: t(".amount"), required: true %> - <%= f.hidden_field :entryable_type, value: "Account::Transaction" %> <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <% end %> diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb new file mode 100644 index 00000000..af819326 --- /dev/null +++ b/app/views/account/transactions/_header.html.erb @@ -0,0 +1,23 @@ +<%# locals: (entry:) %> + +<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> +
+

+ + <%= format_money -entry.amount_money %> + + + + <%= entry.currency %> + +

+ + <% if entry.marked_as_transfer? %> + <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> + <% end %> +
+ + + <%= I18n.l(entry.date, format: :long) %> + +<% end %> diff --git a/app/views/account/transactions/_selection_bar.html.erb b/app/views/account/transactions/_selection_bar.html.erb index 3f72eaab..4fae27fa 100644 --- a/app/views/account/transactions/_selection_bar.html.erb +++ b/app/views/account/transactions/_selection_bar.html.erb @@ -8,7 +8,7 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> - <%= form_with url: mark_transfers_transactions_path, + <%= form_with url: mark_transfers_account_transactions_path, scope: "bulk_update", data: { turbo_frame: "_top", @@ -28,14 +28,14 @@ <% end %> - <%= link_to bulk_edit_transactions_path, + <%= link_to bulk_edit_account_transactions_path, class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit", data: { turbo_frame: "bulk_transaction_edit_drawer" } do %> <%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %> <% end %> - <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index ebdda70d..9a300440 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% transaction, account = entry.account_transaction, entry.account %>
text-sm font-medium p-4"> @@ -20,7 +20,7 @@ <%= content_tag :p, transaction.name %> <% else %> <%= link_to transaction.name, - entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:), + entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -43,7 +43,7 @@
<% else %>
- <%= render "categories/menu", transaction: transaction, origin: origin %> + <%= render "categories/menu", transaction: transaction %>
<% unless show_balance %> diff --git a/app/views/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb similarity index 91% rename from app/views/transactions/bulk_edit.html.erb rename to app/views/account/transactions/bulk_edit.html.erb index dc4ec2a1..0fa23ae6 100644 --- a/app/views/transactions/bulk_edit.html.erb +++ b/app/views/account/transactions/bulk_edit.html.erb @@ -1,8 +1,8 @@ <%= turbo_frame_tag "bulk_transaction_edit_drawer" do %> - <%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %> + class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4"> + <%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
diff --git a/app/views/account/transactions/index.html.erb b/app/views/account/transactions/index.html.erb index a2fa3f27..f30fcfd6 100644 --- a/app/views/account/transactions/index.html.erb +++ b/app/views/account/transactions/index.html.erb @@ -2,7 +2,7 @@

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

- <%= link_to new_transaction_path(account_id: @account), + <%= link_to new_account_transaction_path(account_id: @account), class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> diff --git a/app/views/transactions/new.html.erb b/app/views/account/transactions/new.html.erb similarity index 51% rename from app/views/transactions/new.html.erb rename to app/views/account/transactions/new.html.erb index 0079185d..12c8f2f2 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/account/transactions/new.html.erb @@ -1,3 +1,3 @@ <%= modal_form_wrapper title: t(".new_transaction") do %> - <%= render "form", transaction: @transaction, entry: @entry %> + <%= render "form", entry: @entry %> <% end %> diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index fe75a2f0..ecce8921 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -1,39 +1,14 @@ -<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %> - -<% origin = params[:origin] %> - -<%= drawer do %> -
-
-

- - <%= format_money -entry.amount_money %> - - - - <%= entry.currency %> - -

- - <% if entry.marked_as_transfer? %> - <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> - <% end %> -
- - - <%= I18n.l(entry.date, format: :long) %> - -
+<%= drawer(reload_on_close: true) do %> + <%= render "account/transactions/header", entry: @entry %>
<%= disclosure t(".overview") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> @@ -43,25 +18,25 @@ max: Date.current, "data-auto-submit-form-target": "auto" %> - <% unless entry.marked_as_transfer? %> + <% unless @entry.marked_as_transfer? %>
<%= f.select :nature, - [["Expense", "expense"], ["Income", "income"]], - { container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" }, + [["Expense", "outflow"], ["Income", "inflow"]], + { container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" }, { data: { "auto-submit-form-target": "auto" } } %> <%= f.money_field :amount, label: t(".amount"), container_class: "w-2/3", auto_submit: true, min: 0, - value: entry.amount.abs %> + value: @entry.amount.abs %>
<% end %> <%= f.select :account, options_for_select( Current.family.accounts.alphabetically.pluck(:name, :id), - entry.account_id + @entry.account_id ), { label: t(".account_label") }, { disabled: true } %> @@ -72,55 +47,45 @@ <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> - <%= f.fields_for :entryable do |ef| %> - <% unless entry.marked_as_transfer? %> + <% unless @entry.marked_as_transfer? %> + <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, - Current.family.categories.alphabetically, + Current.family.categories.alphabetically, :id, :name, - { prompt: t(".category_placeholder"), - label: t(".category_label"), - class: "text-gray-400" }, + { label: t(".category_label"), + class: "text-gray-400", include_blank: t(".uncategorized") }, "data-auto-submit-form-target": "auto" %> <%= ef.collection_select :merchant_id, - Current.family.merchants.alphabetically, + Current.family.merchants.alphabetically, :id, :name, - { prompt: t(".merchant_placeholder"), + { include_blank: t(".none"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> - <% end %> - <%= ef.select :tag_ids, - options_for_select( + <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), - transaction.tag_ids - ), - { - multiple: true, - label: t(".tags_label"), - container_class: "h-40" - }, + { + include_blank: t(".none"), + multiple: true, + label: t(".tags_label"), + container_class: "h-40" + }, { "data-auto-submit-form-target": "auto" } %> - + <% end %> <% end %> - <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), - class: "space-y-2", - data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> - <%= f.text_area :notes, + <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5, "data-auto-submit-form-target": "auto" %> - <% end %> + <% end %>
<% end %> @@ -129,11 +94,10 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "p-3", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %>

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

@@ -158,7 +122,7 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(@entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 2d9e5741..0124d3ea 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -8,12 +8,12 @@
- <%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= link_to new_account_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> <%= lucide_icon "minus-circle", class: "w-5 h-5" %> <%= tag.span t(".expense") %> <% end %> - <%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= link_to new_account_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> <%= lucide_icon "plus-circle", class: "w-5 h-5" %> <%= tag.span t(".income") %> <% end %> diff --git a/app/views/account/transfers/_transfer_toggle.html.erb b/app/views/account/transfers/_transfer_toggle.html.erb index 4b3b5e5a..233b11e7 100644 --- a/app/views/account/transfers/_transfer_toggle.html.erb +++ b/app/views/account/transfers/_transfer_toggle.html.erb @@ -1,6 +1,6 @@ <%# locals: (entry:) %> -<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: { +<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: { turbo_confirm: { title: t(".remove_transfer"), body: t(".remove_transfer_body"), diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index 0ceef3c3..68345c73 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -1,9 +1,12 @@ <%# locals: (entry:) %> -<%= styled_form_with model: [entry.account, entry], - url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry), - class: "space-y-4", - data: { turbo: false } do |form| %> +<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %> + <%= form.hidden_field :account_id %> + + <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %> <%= form.money_field :amount, label: t(".amount"), required: true %> diff --git a/app/views/account/valuations/_header.html.erb b/app/views/account/valuations/_header.html.erb new file mode 100644 index 00000000..ca224bb7 --- /dev/null +++ b/app/views/account/valuations/_header.html.erb @@ -0,0 +1,19 @@ +<%# locals: (entry:) %> + +<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> + + <%= t(".balance") %> + + +
+

+ + <%= format_money entry.amount_money %> + +

+
+ + + <%= I18n.l(entry.date, format: :long) %> + +<% end %> diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index a0402be5..b3e9caf4 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% account = entry.account %> <% valuation = entry.account_valuation %> @@ -21,7 +21,7 @@ <%= content_tag :p, entry.name %> <% else %> <%= link_to valuation.name, - account_entry_path(account, entry), + account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> diff --git a/app/views/account/valuations/show.html.erb b/app/views/account/valuations/show.html.erb index d6684795..89b40d8b 100644 --- a/app/views/account/valuations/show.html.erb +++ b/app/views/account/valuations/show.html.erb @@ -1,30 +1,14 @@ <% entry, account = @entry, @entry.account %> -<%= drawer do %> -
- - <%= t(".balance") %> - - -
-

- - <%= format_money entry.amount_money %> - -

-
- - - <%= I18n.l(entry.date, format: :long) %> - -
+<%= drawer(reload_on_close: true) do %> + <%= render "account/valuations/header", entry: %>
<%= disclosure t(".overview") do %>
- <%= styled_form_with model: [account, entry], - url: account_entry_path(account, entry), + <%= styled_form_with model: entry, + url: account_entry_path(entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_field :name, @@ -48,8 +32,8 @@ <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_entry_path(account, entry), + <%= styled_form_with model: entry, + url: account_entry_path(entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_area :notes, @@ -72,7 +56,7 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", data: { turbo_confirm: true, turbo_frame: "_top" } %> diff --git a/app/views/accounts/_chart_loader.html.erb b/app/views/accounts/_chart_loader.html.erb new file mode 100644 index 00000000..56e244d4 --- /dev/null +++ b/app/views/accounts/_chart_loader.html.erb @@ -0,0 +1,5 @@ +
+
+
+

Loading...

+
diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb new file mode 100644 index 00000000..67eee6fb --- /dev/null +++ b/app/views/accounts/chart.html.erb @@ -0,0 +1,32 @@ +<% period = Period.from_param(params[:period]) %> +<% series = @account.series(period: period) %> +<% trend = series.trend %> + +<%= turbo_frame_tag dom_id(@account, :chart_details) do %> +
+ <% if trend.direction.flat? %> + <%= tag.span t(".no_change"), class: "text-gray-500" %> + <% else %> + <%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %> + <% unless trend.percent.infinite? %> + <%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %> + <% end %> + <% end %> + + <%= tag.span period_label(period), class: "text-gray-500" %> +
+ +
+ <% if series %> +
+ <% else %> +
+

No data available for the selected period.

+
+ <% end %> +
+<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 4bf4e2ed..c041b652 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account) do %> +<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 6b825b48..7546748d 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,12 +1,10 @@ <%# locals: (account:, title: nil, tooltip: nil, **args) %> <% period = Period.from_param(params[:period]) %> -<% series = account.series(period: period) %> -<% trend = series.trend %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %> -
-
+
+
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %> @@ -14,19 +12,6 @@
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> - -
- <% if trend.direction.flat? %> - <%= tag.span t(".no_change"), class: "text-gray-500" %> - <% else %> - <%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %> - <% unless trend.percent.infinite? %> - <%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %> - <% end %> - <% end %> - - <%= tag.span period_label(period), class: "text-gray-500" %> -
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> @@ -34,7 +19,7 @@ <% end %>
-
- <%= render "shared/line_chart", series: series %> -
+ <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %> + <%= render "accounts/chart_loader" %> + <% end %>
diff --git a/app/views/accounts/show/_tab.html.erb b/app/views/accounts/show/_tab.html.erb index 4ddd0e6e..36b63fcc 100644 --- a/app/views/accounts/show/_tab.html.erb +++ b/app/views/accounts/show/_tab.html.erb @@ -2,6 +2,7 @@ <%= link_to key.titleize, account_path(account, tab: key), + data: { turbo: false }, class: [ "px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": is_selected diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb index 9235a581..746bcb45 100644 --- a/app/views/categories/_menu.html.erb +++ b/app/views/categories/_menu.html.erb @@ -1,11 +1,12 @@ -<%# locals: (transaction:, origin: nil) %> -
+<%# locals: (transaction:) %> + +

diff --git a/app/views/investments/_cash_tab.html.erb b/app/views/investments/_cash_tab.html.erb index f5e51f28..2ebd3126 100644 --- a/app/views/investments/_cash_tab.html.erb +++ b/app/views/investments/_cash_tab.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %> +<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb index ceb7f32b..9ac9868d 100644 --- a/app/views/investments/_chart.html.erb +++ b/app/views/investments/_chart.html.erb @@ -1,9 +1,5 @@ <%# locals: (account:, **args) %> -<% period = Period.from_param(params[:period]) %> -<% series = account.series(period: period) %> -<% trend = series.trend %> -
diff --git a/app/views/investments/_holdings_tab.html.erb b/app/views/investments/_holdings_tab.html.erb index c61c3a17..9b68eb66 100644 --- a/app/views/investments/_holdings_tab.html.erb +++ b/app/views/investments/_holdings_tab.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %> +<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8c8abc9c..8c8f1d15 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -36,7 +36,7 @@ <%= render_flash_notifications %> <% if Current.family&.syncing? %> - <%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %> + <%= render "shared/syncing_notice" %> <% end %>
diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb new file mode 100644 index 00000000..cc0667a3 --- /dev/null +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -0,0 +1,11 @@ +
+ <%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> +
+ + <%= combobox_security.name.presence || combobox_security.symbol %> + + + <%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym})" %> + +
+
diff --git a/app/views/securities/index.turbo_stream.erb b/app/views/securities/index.turbo_stream.erb new file mode 100644 index 00000000..43b41c2b --- /dev/null +++ b/app/views/securities/index.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= async_combobox_options @securities.map(&:to_combobox_option), + render_in: { partial: "securities/combobox_security" } %> diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb index de796424..050bff21 100644 --- a/app/views/shared/_drawer.html.erb +++ b/app/views/shared/_drawer.html.erb @@ -1,5 +1,10 @@ +<%# locals: (content:, reload_on_close: false) %> + <%= turbo_frame_tag "drawer" do %> - +
diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb new file mode 100644 index 00000000..30e37a97 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,6 @@ +<%# locals: (model:) %> + +
+ <%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %> +

<%= model.errors.full_messages.to_sentence %>

+
diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb index 6f6ed438..2ee020bc 100644 --- a/app/views/shared/_notification.html.erb +++ b/app/views/shared/_notification.html.erb @@ -1,10 +1,9 @@ -<%# locals: (message:, type: "notice", id: nil, **_opts) %> +<%# locals: (message:, type: "notice", **_opts) %> <% type = type.to_sym %> <% action = "animationend->element-removal#remove" if type == :notice %> <%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25", - id: type == :processing ? "syncing-notification" : id, data: { controller: "element-removal", action: action @@ -20,8 +19,6 @@
<%= lucide_icon "x", class: "w-3 h-3" %>
- <% when :processing %> - <%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %> <% end %>
diff --git a/app/views/shared/_syncing_notice.html.erb b/app/views/shared/_syncing_notice.html.erb new file mode 100644 index 00000000..5500dbc8 --- /dev/null +++ b/app/views/shared/_syncing_notice.html.erb @@ -0,0 +1,7 @@ +<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %> +
+ <%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %> +
+ + <%= tag.p t(".syncing"), class: "text-gray-900 text-sm font-medium" %> +<% end %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index 3e532fad..9577395c 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -16,7 +16,7 @@

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

<% end %> - <%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> + <%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %>

New transaction

<% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index c2663da3..61558049 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -29,7 +29,7 @@
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %> - <%= render entries, origin: "transactions" %> + <%= render entries %> <% end %>
diff --git a/app/views/transactions/rules.html.erb b/app/views/transactions/rules.html.erb deleted file mode 100644 index 62222550..00000000 --- a/app/views/transactions/rules.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<% content_for :sidebar do %> - <%= render "settings/nav" %> -<% end %> - -
-

Rules

-
-
-

Transaction rules coming soon...

-
-
-
- <%= previous_setting("Merchants", merchants_path) %> - <%= next_setting("Imports", imports_path) %> -
-
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index b854cf00..6ebccac2 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -80,6 +80,29 @@ ], "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "f158202dcc66f2273ddea5e5296bad7146a50ca6667f49c77372b5b234542334", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/concerns/entryable_resource.rb", + "line": 122, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:account_entry).permit(:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", + "render_path": null, + "location": { + "type": "method", + "class": "EntryableResource", + "method": "entry_params" + }, + "user_input": ":account_id", + "confidence": "High", + "cwe_id": [ + 915 + ], + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -115,6 +138,6 @@ "note": "" } ], - "updated": "2024-11-02 15:02:28 -0400", + "updated": "2024-11-27 15:33:53 -0500", "brakeman_version": "6.2.2" } diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index b742a731..395ac91f 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -2,6 +2,8 @@ en: account: entries: + create: + success: Entry created destroy: success: Entry deleted empty: diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index 99fb3a42..b2e3fe25 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -2,6 +2,8 @@ en: account: holdings: + destroy: + success: Holding deleted holding: per_share: per share shares: "%{qty} shares" diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml index 15b83280..01fd53fa 100644 --- a/config/locales/views/account/trades/en.yml +++ b/config/locales/views/account/trades/en.yml @@ -2,9 +2,6 @@ en: account: trades: - create: - failure: Something went wrong - success: Transaction created successfully. form: account: Transfer account (optional) account_prompt: Search account @@ -15,6 +12,15 @@ en: submit: Add transaction ticker_placeholder: AAPL type: Type + header: + buy: Buy + current_market_price_label: Current Market Price + overview: Overview + purchase_price_label: Purchase Price + purchase_qty_label: Purchase Quantity + sell: Sell + symbol_label: Symbol + total_return_label: Unrealized gain/loss index: amount: Amount new: New transaction @@ -27,7 +33,6 @@ en: show: additional: Additional cost_per_share_label: Cost per Share - current_market_price_label: Current Market Price date_label: Date delete: Delete delete_subtitle: This action cannot be undone @@ -37,12 +42,5 @@ en: exclude_title: Exclude from analytics note_label: Note note_placeholder: Add any additional notes here... - overview: Overview - purchase_price_label: Purchase Price - purchase_qty_label: Purchase Quantity quantity_label: Quantity settings: Settings - symbol_label: Symbol - total_return_label: Unrealized gain/loss - update: - success: Trade updated successfully. diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index 81a88210..af05bcdf 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -2,11 +2,44 @@ en: account: transactions: + bulk_delete: + success: "%{count} transactions deleted" + bulk_edit: + cancel: Cancel + category_label: Category + category_placeholder: Select a category + date_label: Date + details: Details + merchant_label: Merchant + merchant_placeholder: Select a merchant + note_label: Notes + note_placeholder: Enter a note that will be applied to selected transactions + overview: Overview + save: Save + bulk_update: + success: "%{count} transactions updated" + form: + account: Account + account_prompt: Select an Account + amount: Amount + category: Category + category_prompt: Select a Category + date: Date + description: Description + description_placeholder: Describe transaction + expense: Expense + income: Income + submit: Add transaction + transfer: Transfer index: new: New transaction no_transactions: No transactions for this account yet. transaction: transaction transactions: Transactions + mark_transfers: + success: Marked as transfers + new: + new_transaction: New transaction selection_bar: mark_transfers: Mark as transfers? mark_transfers_confirm: Mark as transfers @@ -16,7 +49,6 @@ en: account_label: Account amount: Amount category_label: Category - category_placeholder: Select a category date_label: Date delete: Delete delete_subtitle: This permanently deletes the transaction, affects your historical @@ -27,13 +59,14 @@ en: analytics. exclude_title: Exclude transaction merchant_label: Merchant - merchant_placeholder: Select a merchant name_label: Name nature: Type + none: "(none)" note_label: Notes note_placeholder: Enter a note overview: Overview settings: Settings tags_label: Tags - update: - success: Transaction updated successfully. + uncategorized: "(uncategorized)" + unmark_transfers: + success: Transfer removed diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index 5c542802..c157b6d6 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -2,11 +2,11 @@ en: account: valuations: - create: - success: Valuation created successfully. form: amount: Amount submit: Add balance update + header: + balance: Balance index: change: change date: date @@ -18,7 +18,6 @@ en: title: New balance show: amount: Amount - balance: Balance date_label: Date delete: Delete delete_subtitle: This action cannot be undone diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 585cb37d..8e7359f5 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -31,10 +31,11 @@ en: manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? + chart: + no_change: no change show: chart: balance: Balance - no_change: no change owed: Amount owed menu: confirm_accept: Delete "%{name}" diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index c0598db9..8adc4792 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -1,8 +1,6 @@ --- en: layouts: - application: - syncing: Syncing account data... auth: existing_account: Already have an account? no_account: New to Maybe? diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 80f4fc3d..0e020f42 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -1,6 +1,8 @@ --- en: shared: + syncing_notice: + syncing: Syncing accounts data... confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

" diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index c0533bb4..5e6ab631 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -1,37 +1,6 @@ --- en: transactions: - bulk_delete: - success: "%{count} transactions deleted" - bulk_edit: - cancel: Cancel - category_label: Category - category_placeholder: Select a category - date_label: Date - details: Details - merchant_label: Merchant - merchant_placeholder: Select a merchant - note_label: Notes - note_placeholder: Enter a note that will be applied to selected transactions - overview: Overview - save: Save - bulk_update: - success: "%{count} transactions updated" - create: - success: New transaction created successfully - form: - account: Account - account_prompt: Select an Account - amount: Amount - category: Category - category_prompt: Select a Category - date: Date - description: Description - description_placeholder: Describe transaction - expense: Expense - income: Income - submit: Add transaction - transfer: Transfer header: edit_categories: Edit categories edit_imports: Edit imports @@ -41,10 +10,6 @@ en: index: transaction: transaction transactions: transactions - mark_transfers: - success: Marked as transfer - new: - new_transaction: New transaction searches: filters: amount_filter: @@ -77,5 +42,3 @@ en: equal_to: equal to greater_than: greater than less_than: less than - unmark_transfers: - success: Transfer removed diff --git a/config/routes.rb b/config/routes.rb index 1462b71a..c7c14d91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -69,22 +69,42 @@ Rails.application.routes.draw do member do post :sync - end - - scope module: :account do - resources :holdings, only: %i[index new show destroy] - resources :cashes, only: :index - - resources :transactions, only: %i[index update] - resources :valuations, only: %i[index new create] - resources :trades, only: %i[index new create update] do - get :securities, on: :collection - end - - resources :entries, only: %i[index edit update show destroy] + get :chart end end + namespace :account do + resources :holdings, only: %i[index new show destroy] + resources :cashes, only: :index + + resources :entries, only: :index + + resources :transactions, only: %i[show new create update destroy] do + resource :category, only: :update, controller: :transaction_categories + + collection do + post "bulk_delete" + get "bulk_edit" + post "bulk_update" + post "mark_transfers" + post "unmark_transfers" + end + end + + resources :valuations, only: %i[show new create update destroy] + resources :trades, only: %i[show new create update destroy] + end + + direct :account_entry do |entry, options| + if entry.new_record? + route_for "account_#{entry.entryable_name.pluralize}", options + else + route_for entry.entryable_name, entry, options + end + end + + resources :transactions, only: :index + # Convenience routes for polymorphic paths # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123 direct :account do |model, options| @@ -104,15 +124,7 @@ Rails.application.routes.draw do resources :other_assets, except: :index resources :other_liabilities, except: :index - resources :transactions, only: %i[index new create] do - collection do - post "bulk_delete" - get "bulk_edit" - post "bulk_update" - post "mark_transfers" - post "unmark_transfers" - end - end + resources :securities, only: :index resources :invite_codes, only: %i[index create] diff --git a/db/migrate/20241126211249_add_logo_url_to_security.rb b/db/migrate/20241126211249_add_logo_url_to_security.rb new file mode 100644 index 00000000..e265424d --- /dev/null +++ b/db/migrate/20241126211249_add_logo_url_to_security.rb @@ -0,0 +1,5 @@ +class AddLogoUrlToSecurity < ActiveRecord::Migration[7.2] + def change + add_column :securities, :logo_url, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index e662e3be..945f18b1 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: 2024_11_22_183828) do +ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -508,6 +508,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do t.string "country_code" t.string "exchange_mic" t.string "exchange_acronym" + t.string "logo_url" t.index ["country_code"], name: "index_securities_on_country_code" t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true end diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index d735eb01..c0ce72c4 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -3,63 +3,11 @@ require "test_helper" class Account::EntriesControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @transaction = account_entries :transaction - @valuation = account_entries :valuation - @trade = account_entries :trade + @entry = account_entries(:transaction) end - # ================= - # Shared - # ================= - - test "should destroy entry" do - [ @transaction, @valuation, @trade ].each do |entry| - assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do - delete account_entry_url(entry.account, entry) - end - - assert_redirected_to account_url(entry.account) - assert_enqueued_with(job: SyncJob) - end + test "gets index" do + get account_entries_path(account_id: @entry.account.id) + assert_response :success end - - test "gets show" do - [ @transaction, @valuation, @trade ].each do |entry| - get account_entry_url(entry.account, entry) - assert_response :success - end - end - - test "gets edit" do - [ @valuation ].each do |entry| - get edit_account_entry_url(entry.account, entry) - assert_response :success - end - end - - test "can update generic entry" do - [ @transaction, @valuation, @trade ].each do |entry| - assert_no_difference_in_entries do - patch account_entry_url(entry.account, entry), params: { - account_entry: { - name: "Name", - date: Date.current, - currency: "USD", - amount: 100 - } - } - end - - assert_redirected_to account_entry_url(entry.account, entry) - assert_enqueued_with(job: SyncJob) - end - end - - private - - # Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record - # See `update_only` option of accepts_nested_attributes_for - def assert_no_difference_in_entries(&block) - assert_no_difference [ "Account::Entry.count", "Account::Transaction.count", "Account::Valuation.count" ], &block - end end diff --git a/test/controllers/account/holdings_controller_test.rb b/test/controllers/account/holdings_controller_test.rb index 3a556908..7bca9671 100644 --- a/test/controllers/account/holdings_controller_test.rb +++ b/test/controllers/account/holdings_controller_test.rb @@ -8,12 +8,12 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest end test "gets holdings" do - get account_holdings_url(@account) + get account_holdings_url(account_id: @account.id) assert_response :success end test "gets holding" do - get account_holding_path(@account, @holding) + get account_holding_path(@holding) assert_response :success end @@ -21,10 +21,10 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest test "destroys holding and associated entries" do assert_difference -> { Account::Holding.count } => -1, -> { Account::Entry.count } => -1 do - delete account_holding_path(@account, @holding) + delete account_holding_path(@holding) end - assert_redirected_to account_holdings_path(@account) - assert_empty @account.entries.where(entryable: @account.trades.where(security: @holding.security)) + assert_redirected_to account_path(@holding.account) + assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security)) end end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index eaa4f4c5..cdfd6add 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -1,19 +1,36 @@ require "test_helper" class Account::TradesControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :trade + @entry = account_entries(:trade) end - test "should get index" do - get account_trades_url(@entry.account) - assert_response :success - end + test "updates trade entry" do + assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do + patch account_trade_url(@entry), params: { + account_entry: { + currency: "USD", + entryable_attributes: { + id: @entry.entryable_id, + qty: 20, + price: 20 + } + } + } + end - test "should get new" do - get new_account_trade_url(@entry.account) - assert_response :success + @entry.reload + + assert_enqueued_with job: SyncJob + + assert_equal 20, @entry.account_trade.qty + assert_equal 20, @entry.account_trade.price + assert_equal "USD", @entry.currency + + assert_redirected_to account_url(@entry.account) end test "creates deposit entry" do @@ -22,9 +39,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, -> { Account::Transfer.count } => 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_in", + account_id: @entry.account_id, + type: "deposit", date: Date.current, amount: 10, currency: "USD", @@ -42,9 +60,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, -> { Account::Transfer.count } => 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_out", + account_id: @entry.account_id, + type: "withdrawal", date: Date.current, amount: 10, currency: "USD", @@ -60,9 +79,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 1, -> { Account::Transaction.count } => 1, -> { Account::Transfer.count } => 0 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_out", + account_id: @entry.account_id, + type: "withdrawal", date: Date.current, amount: 10, currency: "USD" @@ -79,8 +99,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "creates interest entry" do assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "interest", date: Date.current, amount: 10, @@ -97,13 +118,15 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "creates trade buy entry" do assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "buy", date: Date.current, ticker: "NVDA (NASDAQ)", qty: 10, - price: 10 + price: 10, + currency: "USD" } } end @@ -112,15 +135,16 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.positive? assert created_entry.account_trade.qty.positive? - assert_equal "Transaction created successfully.", flash[:notice] + assert_equal "Entry created", flash[:notice] assert_enqueued_with job: SyncJob - assert_redirected_to @entry.account + assert_redirected_to account_url(created_entry.account) end test "creates trade sell entry" do assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "sell", ticker: "AAPL (NYSE)", date: Date.current, @@ -135,8 +159,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.negative? assert created_entry.account_trade.qty.negative? - assert_equal "Transaction created successfully.", flash[:notice] + assert_equal "Entry created", flash[:notice] assert_enqueued_with job: SyncJob - assert_redirected_to @entry.account + assert_redirected_to account_url(created_entry.account) end end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index ddda4677..d490bfa7 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -1,40 +1,117 @@ require "test_helper" class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :transaction + @entry = account_entries(:transaction) end - test "should get index" do - get account_transactions_url(@entry.account) - assert_response :success - end - - test "update" do - assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do - patch account_transaction_url(@entry.account, @entry), params: { + test "creates with transaction details" do + assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do + post account_transactions_url, params: { account_entry: { - name: "Name", + account_id: @entry.account_id, + name: "New transaction", date: Date.current, currency: "USD", amount: 100, - nature: "income", - entryable_type: @entry.entryable_type, + nature: "inflow", entryable_attributes: { - id: @entry.entryable_id, tag_ids: [ Tag.first.id, Tag.second.id ], category_id: Category.first.id, - merchant_id: Merchant.first.id, - notes: "test notes", - excluded: false + merchant_id: Merchant.first.id } } } end - assert_equal "Transaction updated successfully.", flash[:notice] - assert_redirected_to account_entry_url(@entry.account, @entry) + created_entry = Account::Entry.order(:created_at).last + + assert_redirected_to account_url(created_entry.account) + assert_equal "Entry created", flash[:notice] assert_enqueued_with(job: SyncJob) end + + test "updates with transaction details" do + assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do + patch account_transaction_url(@entry), params: { + account_entry: { + name: "Updated name", + date: Date.current, + currency: "USD", + amount: 100, + nature: "inflow", + entryable_type: @entry.entryable_type, + notes: "test notes", + excluded: false, + entryable_attributes: { + id: @entry.entryable_id, + tag_ids: [ Tag.first.id, Tag.second.id ], + category_id: Category.first.id, + merchant_id: Merchant.first.id + } + } + } + end + + @entry.reload + + assert_equal "Updated name", @entry.name + assert_equal Date.current, @entry.date + assert_equal "USD", @entry.currency + assert_equal -100, @entry.amount + assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort + assert_equal Category.first.id, @entry.entryable.category_id + assert_equal Merchant.first.id, @entry.entryable.merchant_id + assert_equal "test notes", @entry.notes + assert_equal false, @entry.excluded + + assert_equal "Entry updated", flash[:notice] + assert_redirected_to account_url(@entry.account) + assert_enqueued_with(job: SyncJob) + end + + test "can destroy many transactions at once" do + transactions = @user.family.entries.account_transactions + delete_count = transactions.size + + assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do + post bulk_delete_account_transactions_url, params: { + bulk_delete: { + entry_ids: transactions.pluck(:id) + } + } + end + + assert_redirected_to transactions_url + assert_equal "#{delete_count} transactions deleted", flash[:notice] + end + + test "can update many transactions at once" do + transactions = @user.family.entries.account_transactions + + assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do + post bulk_update_account_transactions_url, params: { + bulk_update: { + entry_ids: transactions.map(&:id), + date: 1.day.ago.to_date, + category_id: Category.second.id, + merchant_id: Merchant.second.id, + notes: "Updated note" + } + } + end + + assert_redirected_to transactions_url + assert_equal "#{transactions.count} transactions updated", flash[:notice] + + transactions.reload.each do |transaction| + assert_equal 1.day.ago.to_date, transaction.date + assert_equal Category.second, transaction.account_transaction.category + assert_equal Merchant.second, transaction.account_transaction.merchant + assert_equal "Updated note", transaction.notes + end + end end diff --git a/test/controllers/account/valuations_controller_test.rb b/test/controllers/account/valuations_controller_test.rb index 1d3daeb7..432c2cd4 100644 --- a/test/controllers/account/valuations_controller_test.rb +++ b/test/controllers/account/valuations_controller_test.rb @@ -1,36 +1,11 @@ require "test_helper" class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :valuation - end - - test "should get index" do - get account_valuations_url(@entry.account) - assert_response :success - end - - test "should get new" do - get new_account_valuation_url(@entry.account) - assert_response :success - end - - test "create" do - assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do - post account_valuations_url(@entry.account), params: { - account_entry: { - name: "Manual valuation", - amount: 19800, - date: Date.current, - currency: "USD" - } - } - end - - assert_equal "Valuation created successfully.", flash[:notice] - assert_enqueued_with job: SyncJob - assert_redirected_to account_valuations_path(@entry.account) + @entry = account_entries(:valuation) end test "error when valuation already exists for date" do @@ -44,7 +19,43 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest } end - assert_equal "Date has already been taken", flash[:alert] - assert_redirected_to @entry.account + assert_response :unprocessable_entity + end + + test "creates entry with basic attributes" do + assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do + post account_valuations_url, params: { + account_entry: { + name: "New entry", + amount: 10000, + currency: "USD", + date: Date.current, + account_id: @entry.account_id + } + } + end + + created_entry = Account::Entry.order(created_at: :desc).first + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(created_entry.account) + end + + test "updates entry with basic attributes" do + assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do + patch account_valuation_url(@entry), params: { + account_entry: { + name: "Updated entry", + amount: 20000, + currency: "USD", + date: Date.current + } + } + end + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(@entry.account) end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 5b73c114..e4920819 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -8,83 +8,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest @transaction = account_entries(:transaction) end - test "should get new" do - get new_transaction_url - assert_response :success - end - - test "prefills account_id" do - get new_transaction_url(account_id: @transaction.account.id) - assert_response :success - assert_select "option[selected][value='#{@transaction.account.id}']" - end - - test "should create transaction" do - account = @user.family.accounts.first - entry_params = { - account_id: account.id, - amount: 100.45, - currency: "USD", - date: Date.current, - name: "Test transaction", - entryable_type: "Account::Transaction", - entryable_attributes: { category_id: categories(:food_and_drink).id } - } - - assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do - post transactions_url, params: { account_entry: entry_params } - end - - assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount - assert_equal "New transaction created successfully", flash[:notice] - assert_enqueued_with(job: SyncJob) - assert_redirected_to account_url(account) - end - - test "expenses are positive" do - assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], 1) do - post transactions_url, params: { - account_entry: { - nature: "expense", - account_id: @transaction.account_id, - amount: @transaction.amount, - currency: @transaction.currency, - date: @transaction.date, - name: @transaction.name, - entryable_type: "Account::Transaction", - entryable_attributes: {} - } - } - end - - created_entry = Account::Entry.order(created_at: :desc).first - - assert_redirected_to account_url(@transaction.account) - assert created_entry.amount.positive?, "Amount should be positive" - end - - test "incomes are negative" do - assert_difference("Account::Transaction.count") do - post transactions_url, params: { - account_entry: { - nature: "income", - account_id: @transaction.account_id, - amount: @transaction.amount, - currency: @transaction.currency, - date: @transaction.date, - name: @transaction.name, - entryable_type: "Account::Transaction", - entryable_attributes: { category_id: categories(:food_and_drink).id } - } - } - end - - created_entry = Account::Entry.order(created_at: :desc).first - - assert_redirected_to account_url(@transaction.account) - assert created_entry.amount.negative?, "Amount should be negative" - end - test "transaction count represents filtered total" do family = families(:empty) sign_in family.users.first @@ -135,46 +58,4 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_dom "#" + dom_id(sorted_transactions.last), count: 1 end - - test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions - delete_count = transactions.size - - assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do - post bulk_delete_transactions_url, params: { - bulk_delete: { - entry_ids: transactions.pluck(:id) - } - } - end - - assert_redirected_to transactions_url - assert_equal "#{delete_count} transactions deleted", flash[:notice] - end - - test "can update many transactions at once" do - transactions = @user.family.entries.account_transactions - - assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do - post bulk_update_transactions_url, params: { - bulk_update: { - entry_ids: transactions.map(&:id), - date: 1.day.ago.to_date, - category_id: Category.second.id, - merchant_id: Merchant.second.id, - notes: "Updated note" - } - } - end - - assert_redirected_to transactions_url - assert_equal "#{transactions.count} transactions updated", flash[:notice] - - transactions.reload.each do |transaction| - assert_equal 1.day.ago.to_date, transaction.date - assert_equal Category.second, transaction.account_transaction.category - assert_equal Merchant.second, transaction.account_transaction.merchant - assert_equal "Updated note", transaction.notes - end - end end diff --git a/test/interfaces/entryable_resource_interface_test.rb b/test/interfaces/entryable_resource_interface_test.rb new file mode 100644 index 00000000..28ba3e75 --- /dev/null +++ b/test/interfaces/entryable_resource_interface_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +module EntryableResourceInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "shows new form" do + get new_polymorphic_url(@entry.entryable) + assert_response :success + end + + test "shows editing drawer" do + get account_entry_url(@entry) + assert_response :success + end + + test "destroys entry" do + assert_difference "Account::Entry.count", -1 do + delete account_entry_url(@entry) + end + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(@entry.account) + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 87855e0d..bc8d3965 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,9 +10,9 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades - Security::SynthComboboxOption.stubs(:find_in_synth).returns([ - Security::SynthComboboxOption.new( - symbol: "AAPL", + Security.stubs(:search).returns([ + Security.new( + ticker: "AAPL", name: "Apple Inc.", logo_url: "https://logo.synthfinance.com/ticker/AAPL", exchange_acronym: "NASDAQ", @@ -37,7 +37,7 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades within_trades do - assert_text "Purchase 10 shares of AAPL" + assert_text "Buy 10.0 shares of AAPL" assert_text "Buy #{shares_qty} shares of AAPL" end end From d592495be5a64e53e1bc9b5a539378e668032dac Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 2 Dec 2024 10:53:16 -0500 Subject: [PATCH 046/626] Fix sync error when security missing --- app/models/plaid_account.rb | 12 +++++++----- app/models/sync.rb | 1 + config/initializers/sentry.rb | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 2c1d0a30..240b7c18 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -158,19 +158,21 @@ class PlaidAccount < ApplicationRecord end def get_security(plaid_security, securities) - security = nil + return nil if plaid_security.nil? - if plaid_security.ticker_symbol.present? - security = plaid_security + security = if plaid_security.ticker_symbol.present? + plaid_security else - security = securities.find { |s| s.security_id == plaid_security.proxy_security_id } + securities.find { |s| s.security_id == plaid_security.proxy_security_id } end + return nil if security.nil? || security.ticker_symbol.blank? + Security.find_or_create_by!( ticker: security.ticker_symbol, exchange_mic: security.market_identifier_code || "XNAS", country_code: "US" - ) if security.present? + ) end def transfer?(plaid_txn) diff --git a/app/models/sync.rb b/app/models/sync.rb index 84e078da..e818b486 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -29,6 +29,7 @@ class Sync < ApplicationRecord end def fail!(error) + Sentry.capture_exception(error) update! status: :failed, error: error.message, last_ran_at: Time.current end end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 56e6b4fc..e4ce7417 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -1,8 +1,9 @@ if ENV["SENTRY_DSN"].present? Sentry.init do |config| config.dsn = ENV["SENTRY_DSN"] + config.environment = ENV["RAILS_ENV"] config.breadcrumbs_logger = [ :active_support_logger, :http_logger ] - config.enabled_environments = %w[production] + config.enabled_environments = %w[development production] # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. From 1b8064b9fd800d7060fc18f6ddc6198313ea1091 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:01:36 -0500 Subject: [PATCH 047/626] Bump pagy from 9.3.1 to 9.3.2 (#1513) Bumps [pagy](https://github.com/ddnexus/pagy) from 9.3.1 to 9.3.2. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/9.3.1...9.3.2) --- updated-dependencies: - dependency-name: pagy 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 075902ea..b43f737b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.3.1) + pagy (9.3.2) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) From e026f688957053d239c586fba34652895350f460 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:01:45 -0500 Subject: [PATCH 048/626] Bump selenium-webdriver from 4.26.0 to 4.27.0 (#1512) Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.26.0 to 4.27.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.26.0...selenium-4.27.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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index b43f737b..91e58f53 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -403,7 +403,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.3.2) - selenium-webdriver (4.26.0) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From 14fd5913fe779eecb00a938c74b167113dc956d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:01:54 -0500 Subject: [PATCH 049/626] Bump aws-sdk-s3 from 1.173.0 to 1.175.0 (#1511) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.173.0 to 1.175.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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 91e58f53..6745d2ab 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.1013.0) - aws-sdk-core (3.213.0) + aws-partitions (1.1015.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -92,7 +92,7 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.173.0) + aws-sdk-s3 (1.175.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 890638e06d20afcff897dbdc4e2ddbd79d4f8813 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:02:00 -0500 Subject: [PATCH 050/626] Bump mocha from 2.6.0 to 2.6.1 (#1510) Bumps [mocha](https://github.com/freerange/mocha) from 2.6.0 to 2.6.1. - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v2.6.0...v2.6.1) --- updated-dependencies: - dependency-name: mocha 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6745d2ab..8ec25b3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -249,7 +249,7 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.2) - mocha (2.6.0) + mocha (2.6.1) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) From d73e7eacce2583d9377fd48cea1cac9fbe8307bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:02:09 -0500 Subject: [PATCH 051/626] Bump good_job from 4.5.0 to 4.5.1 (#1509) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.5.0 to 4.5.1. - [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.5.0...v4.5.1) --- updated-dependencies: - dependency-name: good_job 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 8ec25b3d..a2ec50f6 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.5.0) + good_job (4.5.1) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) @@ -355,7 +355,7 @@ GEM psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.9.2) - reline (0.5.11) + reline (0.5.12) io-console (~> 0.5) rexml (3.3.9) rubocop (1.67.0) From 9ec94cd1fab31fcf7316d7ca98a94d5459a7a169 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 2 Dec 2024 12:04:54 -0500 Subject: [PATCH 052/626] Add context to plaid sync errors --- app/models/family.rb | 6 +++++- app/models/plaid_item.rb | 11 ++++++++++- app/models/sync.rb | 8 ++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/models/family.rb b/app/models/family.rb index e32c0d78..999e268a 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -28,9 +28,13 @@ class Family < ApplicationRecord account.sync_data(start_date: start_date) end + plaid_data = [] + plaid_items.each do |plaid_item| - plaid_item.sync_data(start_date: start_date) + plaid_data << plaid_item.sync_data(start_date: start_date) end + + plaid_data end def post_sync diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 881cba17..79087900 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -35,11 +35,13 @@ class PlaidItem < ApplicationRecord def sync_data(start_date: nil) update!(last_synced_at: Time.current) - fetch_and_load_plaid_data + plaid_data = fetch_and_load_plaid_data accounts.each do |account| account.sync_data(start_date: start_date) end + + plaid_data end def post_sync @@ -53,10 +55,12 @@ class PlaidItem < ApplicationRecord private def fetch_and_load_plaid_data + data = {} item = plaid_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 + data[:accounts] = fetched_accounts || [] internal_plaid_accounts = fetched_accounts.map do |account| internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family) @@ -65,6 +69,7 @@ class PlaidItem < ApplicationRecord end fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) + data[:transactions] = fetched_transactions || [] if fetched_transactions transaction do @@ -81,6 +86,7 @@ class PlaidItem < ApplicationRecord end fetched_investments = safe_fetch_plaid_data(:get_item_investments) + data[:investments] = fetched_investments || [] if fetched_investments transaction do @@ -95,6 +101,7 @@ class PlaidItem < ApplicationRecord end fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) + data[:liabilities] = fetched_liabilities || [] if fetched_liabilities transaction do @@ -109,6 +116,8 @@ class PlaidItem < ApplicationRecord end end end + + data end def safe_fetch_plaid_data(method) diff --git a/app/models/sync.rb b/app/models/sync.rb index e818b486..6e09fb45 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -9,7 +9,8 @@ class Sync < ApplicationRecord start! begin - syncable.sync_data(start_date: start_date) + data = syncable.sync_data(start_date: start_date) + update!(data: data) if data complete! rescue StandardError => error fail! error @@ -29,7 +30,10 @@ class Sync < ApplicationRecord end def fail!(error) - Sentry.capture_exception(error) + Sentry.capture_exception(error) do |scope| + scope.set_context("sync", { id: id }) + end + update! status: :failed, error: error.message, last_ran_at: Time.current end end From c456950de87598717e45a5ae838615483242025a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 2 Dec 2024 14:06:56 -0500 Subject: [PATCH 053/626] Fix transaction filters selection bar controller error --- app/views/transactions/index.html.erb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 61558049..4008af8e 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -10,10 +10,11 @@ class="overflow-y-auto flex flex-col bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4"> <%= render "transactions/searches/search" %> + + <% if @transaction_entries.present? %> -
From 565103caf3f6fd83157097dd3590fb50c7aebd28 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 3 Dec 2024 11:09:57 -0600 Subject: [PATCH 054/626] Updated domain to maybefinance.com --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- README.md | 2 +- app/views/layouts/_footer.html.erb | 2 +- app/views/layouts/_sidebar.html.erb | 2 +- app/views/pages/feedback.html.erb | 2 +- db/schema.rb | 41 ++++++++++++++++++++++++++++ test/fixtures/users.yml | 2 +- 7 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9a250838..28ba17e6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -21,7 +21,7 @@ Steps to reproduce the 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.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on. +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. diff --git a/README.md b/README.md index 17ff8b95..abe0d335 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Maybe: The OS for your personal finances Get -involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues) +involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues) _If you're looking for the previous React codebase, you can find it at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._ diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 69694a1d..38cb51b4 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -2,6 +2,6 @@

© <%= Date.current.year %>, Maybe Finance, Inc.

-

<%= link_to t(".privacy_policy"), "https://maybe.co/privacy", class: "underline hover:text-gray-600" %> • <%= link_to t(".terms_of_service"), "https://maybe.co/tos", class: "underline hover:text-gray-600" %>

+

<%= link_to t(".privacy_policy"), "https://maybefinance.com/privacy", class: "underline hover:text-gray-600" %> • <%= link_to t(".terms_of_service"), "https://maybefinance.com/tos", class: "underline hover:text-gray-600" %>

diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index c274dc75..ff168f2d 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -55,7 +55,7 @@ Contact <% end %> <% else %> - <%= link_to "mailto:hello@maybe.co", class: "flex gap-2 items-center hover:bg-gray-50 rounded-lg px-3 py-2", onclick: "Intercom('showNewMessage'); return false;" do %> + <%= link_to "mailto:hello@maybefinance.com", class: "flex gap-2 items-center hover:bg-gray-50 rounded-lg px-3 py-2", onclick: "Intercom('showNewMessage'); return false;" do %> <%= lucide_icon("message-square-more", class: "w-5 h-5 text-gray-500 shrink-0") %> Contact <% end %> diff --git a/app/views/pages/feedback.html.erb b/app/views/pages/feedback.html.erb index fdc5c4ad..42e66b24 100644 --- a/app/views/pages/feedback.html.erb +++ b/app/views/pages/feedback.html.erb @@ -18,7 +18,7 @@ File a bug report <% end %> <% else %> - <%= link_to "mailto:hello@maybe.co", class: "w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-gray-50", onclick: "Intercom('showNewMessage'); return false;" do %> + <%= link_to "mailto:hello@maybefinance.com", class: "w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-gray-50", onclick: "Intercom('showNewMessage'); return false;" do %> <%= lucide_icon "bug", class: "w-8 h-8 mb-2" %> File a bug report <% end %> diff --git a/db/schema.rb b/db/schema.rb index 945f18b1..5b055ace 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -174,6 +174,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.index ["family_id"], name: "index_categories_on_family_id" end + create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "title" + t.text "summary" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_chats_on_user_id" + end + create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -452,6 +461,33 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.index ["family_id"], name: "index_merchants_on_family_id" end + create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "chat_id", null: false + t.uuid "user_id" + t.text "content" + t.text "log" + t.string "role" + t.string "status", default: "pending" + t.boolean "hidden", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["chat_id"], name: "index_messages_on_chat_id" + t.index ["user_id"], name: "index_messages_on_user_id" + end + + create_table "metrics", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.uuid "account_id" + t.string "kind", null: false + t.string "subkind" + t.date "date", null: false + t.decimal "value", precision: 10, scale: 2, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_metrics_on_account_id" + t.index ["family_id"], name: "index_metrics_on_family_id" + end + create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -639,6 +675,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "categories", "families" + add_foreign_key "chats", "users" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" @@ -647,6 +684,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" + add_foreign_key "messages", "chats" + add_foreign_key "messages", "users" + add_foreign_key "metrics", "accounts" + add_foreign_key "metrics", "families" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index cb4252ce..d7e7444d 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -10,7 +10,7 @@ maybe_support_staff: family: empty first_name: Support last_name: Admin - email: support@maybe.co + email: support@maybefinance.com password_digest: <%= BCrypt::Password.create('password') %> role: super_admin onboarded_at: <%= 3.days.ago %> From 13bec4599fc92554b46c047c5653c5e9ad17f048 Mon Sep 17 00:00:00 2001 From: Nikhil Badyal <59223300+nikhilbadyal@users.noreply.github.com> Date: Wed, 4 Dec 2024 00:36:59 +0530 Subject: [PATCH 055/626] Handle invalid API key (#1515) * Handle invalid API key * Show error on invalid API key --- app/models/family.rb | 6 +++++- app/views/pages/dashboard.html.erb | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/models/family.rb b/app/models/family.rb index 999e268a..bae32843 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -159,7 +159,11 @@ class Family < ApplicationRecord end def synth_overage? - self.class.synth_provider && self.class.synth_provider.usage.utilization >= 100 + self.class.synth_provider&.usage&.utilization.to_i >= 100 + end + + def synth_valid? + self.class.synth_provider&.healthy? end def subscribed? diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index a2e5c66b..06a9115e 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,8 +1,14 @@
- <% if self_hosted? && Current.family&.synth_overage? %> - + <% if self_hosted? %> + <% if Current.family&.synth_overage? %> + + <% elsif !Current.family&.synth_valid? %> + + <% end %> <% end %>
From 2cba5177ba08db67c606571b66edd33293c8355a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 4 Dec 2024 18:40:43 -0500 Subject: [PATCH 056/626] Revert out-of-sync schema changes --- db/schema.rb | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 5b055ace..945f18b1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -174,15 +174,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.index ["family_id"], name: "index_categories_on_family_id" end - create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "title" - t.text "summary" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["user_id"], name: "index_chats_on_user_id" - end - create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -461,33 +452,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.index ["family_id"], name: "index_merchants_on_family_id" end - create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "chat_id", null: false - t.uuid "user_id" - t.text "content" - t.text "log" - t.string "role" - t.string "status", default: "pending" - t.boolean "hidden", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["chat_id"], name: "index_messages_on_chat_id" - t.index ["user_id"], name: "index_messages_on_user_id" - end - - create_table "metrics", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "account_id" - t.string "kind", null: false - t.string "subkind" - t.date "date", null: false - t.decimal "value", precision: 10, scale: 2, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_metrics_on_account_id" - t.index ["family_id"], name: "index_metrics_on_family_id" - end - create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -675,7 +639,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "categories", "families" - add_foreign_key "chats", "users" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" @@ -684,10 +647,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" - add_foreign_key "messages", "chats" - add_foreign_key "messages", "users" - add_foreign_key "metrics", "accounts" - add_foreign_key "metrics", "families" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" From a9daba16c15a246737b417869cda42ec6de90258 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 5 Dec 2024 08:39:16 -0500 Subject: [PATCH 057/626] Fix account activity view search --- app/views/account/entries/index.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index c66d3767..1ce47409 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -26,7 +26,7 @@
- <%= form_with url: account_entries_path(@account), + <%= form_with url: account_entries_path, id: "entries-search", scope: :q, method: :get, @@ -35,6 +35,7 @@
<%= 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], From 48e306a6143c1f0c257e0b0088f88e67ff4e0866 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:22:00 -0500 Subject: [PATCH 058/626] Bump mocha from 2.6.1 to 2.7.0 (#1523) Bumps [mocha](https://github.com/freerange/mocha) from 2.6.1 to 2.7.0. - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: mocha 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a2ec50f6..8d831e9e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -249,7 +249,7 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.2) - mocha (2.6.1) + mocha (2.7.0) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) From 13cf4d70dfc48901d05920ed15323b19352642ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:31:03 -0500 Subject: [PATCH 059/626] Bump sentry-rails from 5.21.0 to 5.22.0 (#1522) Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.21.0 to 5.22.0. - [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.21.0...5.22.0) --- updated-dependencies: - dependency-name: sentry-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 | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8d831e9e..e1899382 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -218,7 +218,7 @@ GEM nokogiri (>= 1.6) intercom-rails (1.0.1) activesupport (> 4.0) - io-console (0.7.2) + io-console (0.8.0) irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -235,7 +235,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.1) + logger (1.6.2) loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -248,7 +248,7 @@ GEM matrix (0.4.2) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.2) + minitest (5.25.4) mocha (2.7.0) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) @@ -265,17 +265,17 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.16.7-aarch64-linux) + nokogiri (1.17.0-aarch64-linux) racc (~> 1.4) - nokogiri (1.16.7-arm-linux) + nokogiri (1.17.0-arm-linux) racc (~> 1.4) - nokogiri (1.16.7-arm64-darwin) + nokogiri (1.17.0-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86-linux) + nokogiri (1.17.0-x86-linux) racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) + nokogiri (1.17.0-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.17.0-x86_64-linux) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) @@ -295,7 +295,8 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.0) + psych (5.2.1) + date stringio public_suffix (6.0.1) puma (6.5.0) @@ -327,9 +328,9 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.1) loofah (~> 2.21) - nokogiri (~> 1.14) + 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) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) @@ -402,17 +403,17 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - securerandom (0.3.2) + securerandom (0.4.0) selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.21.0) + sentry-rails (5.22.0) railties (>= 5.0) - sentry-ruby (~> 5.21.0) - sentry-ruby (5.21.0) + sentry-ruby (~> 5.22.0) + sentry-ruby (5.22.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) simplecov (0.22.0) @@ -448,7 +449,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) uri (1.0.2) - useragent (0.16.10) + useragent (0.16.11) vcr (6.3.1) base64 web-console (4.2.1) From ee79016e2a9246280e50a4c7fb88ef85390745c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:31:20 -0500 Subject: [PATCH 060/626] Bump pagy from 9.3.2 to 9.3.3 (#1520) Bumps [pagy](https://github.com/ddnexus/pagy) from 9.3.2 to 9.3.3. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/9.3.2...9.3.3) --- updated-dependencies: - dependency-name: pagy 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 e1899382..892a0d35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.3.2) + pagy (9.3.3) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) From a59ca5b7c63f1f70f3f612ff620ea66c9a9537e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:31:29 -0500 Subject: [PATCH 061/626] Bump aws-sdk-s3 from 1.175.0 to 1.176.0 (#1519) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.175.0 to 1.176.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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 892a0d35..d5c8621f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,7 +83,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1015.0) + aws-partitions (1.1018.0) aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -92,7 +92,7 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.175.0) + aws-sdk-s3 (1.176.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 49c353e10c10dae25e45da7d73699f73d044399f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Dec 2024 17:41:20 -0500 Subject: [PATCH 062/626] Plaid portfolio sync algorithm and calculation improvements (#1526) * Start tests rework * Cash balance on schema * Add reverse syncer * Reverse balance sync with holdings * Reverse holdings sync * Reverse holdings sync should work with only trade entries * Consolidate brokerage cash * Add forward sync option * Update new balance info after syncs * Intraday balance calculator and sync fixes * Show only balance for trade entries * Tests passing * Update Gemfile.lock * Cleanup, performance improvements * Remove account reloads for reliable sync outputs * Simplify valuation view logic * Special handling for Plaid cash holding --- Gemfile | 1 - Gemfile.lock | 2 - app/controllers/account/cashes_controller.rb | 7 - .../account/holdings_controller.rb | 2 - app/controllers/accounts_controller.rb | 6 +- app/controllers/concerns/localize.rb | 6 + app/controllers/users_controller.rb | 2 +- app/helpers/account/entries_helper.rb | 11 +- .../{cashes_helper.rb => holdings_helper.rb} | 10 +- app/helpers/languages_helper.rb | 4 + app/models/account.rb | 24 +- app/models/account/balance/calculator.rb | 57 ----- app/models/account/balance/converter.rb | 46 ---- app/models/account/balance/loader.rb | 42 ---- app/models/account/balance/syncer.rb | 51 ---- app/models/account/balance_calculator.rb | 121 +++++++++ .../account/balance_trend_calculator.rb | 94 +++++++ app/models/account/entry.rb | 78 ++---- app/models/account/holding.rb | 4 +- app/models/account/holding/syncer.rb | 136 ----------- app/models/account/holding_calculator.rb | 154 ++++++++++++ app/models/account/syncer.rb | 104 ++++++++ app/models/account/trade_builder.rb | 2 +- app/models/account/valuation.rb | 40 --- app/models/concerns/accountable.rb | 19 -- app/models/gapfiller.rb | 48 ---- app/models/investment.rb | 33 --- app/models/plaid_account.rb | 1 + app/views/account/cashes/_cash.html.erb | 21 -- app/views/account/cashes/index.html.erb | 18 -- app/views/account/entries/_entry.html.erb | 4 +- app/views/account/entries/index.html.erb | 7 +- app/views/account/holdings/_cash.html.erb | 32 +++ app/views/account/holdings/index.html.erb | 8 +- app/views/account/trades/_form.html.erb | 2 +- app/views/account/trades/_trade.html.erb | 10 +- app/views/account/trades/show.html.erb | 2 +- app/views/account/transactions/_form.html.erb | 2 +- .../transactions/_transaction.html.erb | 14 +- app/views/account/transfers/_form.html.erb | 2 +- app/views/account/valuations/_form.html.erb | 2 +- .../account/valuations/_valuation.html.erb | 20 +- app/views/accounts/show/_chart.html.erb | 2 +- app/views/investments/_cash_tab.html.erb | 5 - app/views/investments/_chart.html.erb | 21 -- app/views/investments/_value_tooltip.html.erb | 29 ++- app/views/investments/show.html.erb | 14 +- app/views/settings/preferences/show.html.erb | 5 + app/views/shared/_progress_circle.html.erb | 33 ++- config/locales/defaults/et.yml | 2 +- config/locales/views/account/cashes/en.yml | 8 - config/locales/views/account/holdings/en.yml | 2 + .../locales/views/account/valuations/en.yml | 2 + config/locales/views/accounts/en.yml | 4 +- config/locales/views/investments/en.yml | 7 +- config/locales/views/settings/en.yml | 1 + config/locales/views/shared/en.yml | 4 +- config/routes.rb | 1 - .../20241204235400_add_balance_components.rb | 6 + .../20241207002408_add_family_timezone.rb | 5 + db/schema.rb | 5 +- test/fixtures/accounts.yml | 1 + test/fixtures/investments.yml | 2 +- test/models/account/balance/syncer_test.rb | 153 ------------ .../models/account/balance_calculator_test.rb | 156 ++++++++++++ test/models/account/entry_test.rb | 31 +-- test/models/account/holding/syncer_test.rb | 145 ----------- .../models/account/holding_calculator_test.rb | 231 ++++++++++++++++++ test/models/account/holding_test.rb | 7 +- test/models/account/syncer_test.rb | 54 ++++ test/system/trades_test.rb | 11 +- test/system/transactions_test.rb | 2 +- 72 files changed, 1152 insertions(+), 1046 deletions(-) delete mode 100644 app/controllers/account/cashes_controller.rb rename app/helpers/account/{cashes_helper.rb => holdings_helper.rb} (55%) delete mode 100644 app/models/account/balance/calculator.rb delete mode 100644 app/models/account/balance/converter.rb delete mode 100644 app/models/account/balance/loader.rb delete mode 100644 app/models/account/balance/syncer.rb create mode 100644 app/models/account/balance_calculator.rb create mode 100644 app/models/account/balance_trend_calculator.rb delete mode 100644 app/models/account/holding/syncer.rb create mode 100644 app/models/account/holding_calculator.rb create mode 100644 app/models/account/syncer.rb delete mode 100644 app/models/gapfiller.rb delete mode 100644 app/views/account/cashes/_cash.html.erb delete mode 100644 app/views/account/cashes/index.html.erb create mode 100644 app/views/account/holdings/_cash.html.erb delete mode 100644 app/views/investments/_cash_tab.html.erb delete mode 100644 app/views/investments/_chart.html.erb delete mode 100644 config/locales/views/account/cashes/en.yml create mode 100644 db/migrate/20241204235400_add_balance_components.rb create mode 100644 db/migrate/20241207002408_add_family_timezone.rb delete mode 100644 test/models/account/balance/syncer_test.rb create mode 100644 test/models/account/balance_calculator_test.rb delete mode 100644 test/models/account/holding/syncer_test.rb create mode 100644 test/models/account/holding_calculator_test.rb create mode 100644 test/models/account/syncer_test.rb diff --git a/Gemfile b/Gemfile index 093377c5..10ad4ba6 100644 --- a/Gemfile +++ b/Gemfile @@ -50,7 +50,6 @@ gem "csv" gem "redcarpet" gem "stripe" gem "intercom-rails" -gem "holidays" gem "plaid" group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index d5c8621f..7e2318e0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,7 +185,6 @@ GEM thor (>= 1.0.0) hashdiff (1.1.1) highline (3.0.1) - holidays (8.8.0) hotwire-livereload (1.4.1) actioncable (>= 6.0.0) listen (>= 3.0.0) @@ -493,7 +492,6 @@ DEPENDENCIES faraday-multipart faraday-retry good_job - holidays hotwire-livereload hotwire_combobox i18n-tasks diff --git a/app/controllers/account/cashes_controller.rb b/app/controllers/account/cashes_controller.rb deleted file mode 100644 index f94582ce..00000000 --- a/app/controllers/account/cashes_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Account::CashesController < ApplicationController - layout :with_sidebar - - def index - @account = Current.family.accounts.find(params[:account_id]) - end -end diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index c316b854..174d45c6 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -5,8 +5,6 @@ class Account::HoldingsController < ApplicationController def index @account = Current.family.accounts.find(params[:account_id]) - @holdings = Current.family.holdings.current - @holdings = @holdings.where(account: @account) if @account end def show diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8d0c27c9..4adcf710 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.manual.alphabetically - @plaid_items = Current.family.plaid_items.ordered + @manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically + @plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered end def summary @@ -14,7 +14,7 @@ class AccountsController < ApplicationController @net_worth_series = snapshot[:net_worth_series] @asset_series = snapshot[:asset_series] @liability_series = snapshot[:liability_series] - @accounts = Current.family.accounts + @accounts = Current.family.accounts.active @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency) end diff --git a/app/controllers/concerns/localize.rb b/app/controllers/concerns/localize.rb index 5a549347..f3b558c1 100644 --- a/app/controllers/concerns/localize.rb +++ b/app/controllers/concerns/localize.rb @@ -3,6 +3,7 @@ module Localize included do around_action :switch_locale + around_action :switch_timezone end private @@ -10,4 +11,9 @@ module Localize locale = Current.family.try(:locale) || I18n.default_locale I18n.with_locale(locale, &action) end + + def switch_timezone(&action) + timezone = Current.family.try(:timezone) || Time.zone + Time.use_zone(timezone, &action) + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2dfae623..beb85197 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -41,7 +41,7 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ] + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ] ) end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 148a497b..359a241a 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -13,17 +13,12 @@ module Account::EntriesHelper end def entries_by_date(entries, selectable: true, totals: false) - entries.group_by(&:date).map do |date, grouped_entries| - # Valuations always go first, then sort by created_at desc - sorted_entries = grouped_entries.sort_by do |entry| - [ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ] - end - + entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries| content = capture do - yield sorted_entries + yield grouped_entries end - render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: } + render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: } end.join.html_safe end diff --git a/app/helpers/account/cashes_helper.rb b/app/helpers/account/holdings_helper.rb similarity index 55% rename from app/helpers/account/cashes_helper.rb rename to app/helpers/account/holdings_helper.rb index ed8c2dfc..c9ed7e03 100644 --- a/app/helpers/account/cashes_helper.rb +++ b/app/helpers/account/holdings_helper.rb @@ -1,13 +1,13 @@ -module Account::CashesHelper - def brokerage_cash(account) +module Account::HoldingsHelper + def brokerage_cash_holding(account) currency = Money::Currency.new(account.currency) account.holdings.build \ date: Date.current, - qty: account.balance, + qty: account.cash_balance, price: 1, - amount: account.balance, - currency: account.currency, + amount: account.cash_balance, + currency: currency.iso_code, security: Security.new(ticker: currency.iso_code, name: currency.name) end end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index af4a33f8..883bfad6 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -363,4 +363,8 @@ module LanguagesHelper end .sort_by { |label, locale| label } end + + def timezone_options + ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] } + end end diff --git a/app/models/account.rb b/app/models/account.rb index 50fa6f56..feccd7d6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -16,7 +16,7 @@ class Account < ApplicationRecord has_many :balances, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy - monetize :balance + monetize :balance, :cash_balance enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } @@ -32,8 +32,6 @@ class Account < ApplicationRecord accepts_nested_attributes_for :accountable, update_only: true - delegate :value, :series, to: :accountable - class << self def by_group(period: Period.all, currency: Money.default_currency.iso_code) grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) } @@ -59,7 +57,7 @@ class Account < ApplicationRecord def create_and_sync(attributes) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty - account = new(attributes) + account = new(attributes.merge(cash_balance: attributes[:balance])) transaction do # Create 2 valuations for new accounts to establish a value history for users to see @@ -94,15 +92,27 @@ class Account < ApplicationRecord def sync_data(start_date: nil) update!(last_synced_at: Time.current) - resolve_stale_issues - Balance::Syncer.new(self, start_date: start_date).run - Holding::Syncer.new(self, start_date: start_date).run + Syncer.new(self, start_date: start_date).run end def post_sync + broadcast_remove_to(family, target: "syncing-notice") + resolve_stale_issues accountable.post_sync end + def series(period: Period.last_30_days, currency: nil) + balance_series = balances.in_period(period).where(currency: currency || self.currency) + + if balance_series.empty? && period.date_range.end == Date.current + TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ]) + else + TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down") + end + rescue Money::ConversionError + TimeSeries.new([]) + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb deleted file mode 100644 index 04dfb381..00000000 --- a/app/models/account/balance/calculator.rb +++ /dev/null @@ -1,57 +0,0 @@ -class Account::Balance::Calculator - def initialize(account, sync_start_date) - @account = account - @sync_start_date = sync_start_date - end - - def calculate(is_partial_sync: false) - cached_entries = account.entries.where("date >= ?", sync_start_date).to_a - sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries) - - prior_balance = sync_starting_balance - - (sync_start_date..Date.current).map do |date| - current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:) - - prior_balance = current_balance - - build_balance(date, current_balance) - end - end - - private - attr_reader :account, :sync_start_date - - def find_start_balance_for_partial_sync - account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance - end - - def find_start_balance_for_full_sync(cached_entries) - account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? }) - end - - def calculate_balance_for_date(date, entries:, prior_balance:) - valuation = entries.find { |e| e.date == date && e.account_valuation? } - - return valuation.amount if valuation - - entries = entries.select { |e| e.date == date } - - prior_balance - net_entry_flows(entries) - end - - def net_entry_flows(entries, target_currency = account.currency) - converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } - - flows = converted_entry_amounts.sum(&:amount) - - account.liability? ? flows * -1 : flows - end - - def build_balance(date, balance, currency = nil) - account.balances.build \ - date: date, - balance: balance, - currency: currency || account.currency - end -end diff --git a/app/models/account/balance/converter.rb b/app/models/account/balance/converter.rb deleted file mode 100644 index f5e55749..00000000 --- a/app/models/account/balance/converter.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Account::Balance::Converter - def initialize(account, sync_start_date) - @account = account - @sync_start_date = sync_start_date - end - - def convert(balances) - calculate_converted_balances(balances) - end - - private - attr_reader :account, :sync_start_date - - def calculate_converted_balances(balances) - from_currency = account.currency - to_currency = account.family.currency - - if ExchangeRate.exchange_rates_provider.nil? - account.observe_missing_exchange_rate_provider - return [] - end - - exchange_rates = ExchangeRate.find_rates from: from_currency, - to: to_currency, - start_date: sync_start_date - - missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date) - - if missing_exchange_rates.any? - account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates) - return [] - end - - balances.map do |balance| - exchange_rate = exchange_rates.find { |er| er.date == balance.date } - build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) - end - end - - def build_balance(date, balance, currency = nil) - account.balances.build \ - date: date, - balance: balance, - currency: currency || account.currency - end -end diff --git a/app/models/account/balance/loader.rb b/app/models/account/balance/loader.rb deleted file mode 100644 index 56c02011..00000000 --- a/app/models/account/balance/loader.rb +++ /dev/null @@ -1,42 +0,0 @@ -class Account::Balance::Loader - def initialize(account) - @account = account - end - - def load(balances, start_date) - Account::Balance.transaction do - upsert_balances!(balances) - purge_stale_balances!(start_date) - - account.reload - - update_account_balance!(balances) - end - end - - private - attr_reader :account - - def update_account_balance!(balances) - last_balance = balances.select { |db| db.currency == account.currency }.last&.balance - - if account.plaid_account.present? - account.update! balance: account.plaid_account.current_balance || last_balance - else - account.update! balance: last_balance if last_balance.present? - end - end - - def upsert_balances!(balances) - current_time = Time.now - balances_to_upsert = balances.map do |balance| - balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time) - end - - account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) - end - - def purge_stale_balances!(start_date) - account.balances.delete_by("date < ?", start_date) - end -end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb deleted file mode 100644 index 24a43f8b..00000000 --- a/app/models/account/balance/syncer.rb +++ /dev/null @@ -1,51 +0,0 @@ -class Account::Balance::Syncer - def initialize(account, start_date: nil) - @account = account - @provided_start_date = start_date - @sync_start_date = calculate_sync_start_date(start_date) - @loader = Account::Balance::Loader.new(account) - @converter = Account::Balance::Converter.new(account, sync_start_date) - @calculator = Account::Balance::Calculator.new(account, sync_start_date) - end - - def run - daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?) - daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency - - loader.load(daily_balances, account_start_date) - rescue Money::ConversionError => e - account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ]) - end - - private - - attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator - - def account_start_date - @account_start_date ||= begin - oldest_entry = account.entries.chronological.first - - return Date.current unless oldest_entry.present? - - if oldest_entry.account_valuation? - oldest_entry.date - else - oldest_entry.date - 1.day - end - end - end - - def calculate_sync_start_date(provided_start_date) - return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date) - - account_start_date - end - - def prior_balance_available?(date) - account.balances.find_by(currency: account.currency, date: date - 1.day).present? - end - - def is_partial_sync? - sync_start_date == provided_start_date - end -end diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb new file mode 100644 index 00000000..55e094ed --- /dev/null +++ b/app/models/account/balance_calculator.rb @@ -0,0 +1,121 @@ +class Account::BalanceCalculator + def initialize(account, holdings: nil) + @account = account + @holdings = holdings || [] + end + + def calculate(reverse: false, start_date: nil) + cash_balances = reverse ? reverse_cash_balances : forward_cash_balances + + cash_balances.map do |balance| + holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) + balance.balance = balance.balance + holdings_value + balance + end + end + + private + attr_reader :account, :holdings + + def oldest_date + converted_entries.first ? converted_entries.first.date - 1.day : Date.current + end + + def reverse_cash_balances + prior_balance = account.cash_balance + + Date.current.downto(oldest_date).map do |date| + entries_for_date = converted_entries.select { |e| e.date == date } + holdings_for_date = converted_holdings.select { |h| h.date == date } + + valuation = entries_for_date.find { |e| e.account_valuation? } + + current_balance = if valuation + # To get this to a cash valuation, we back out holdings value on day + valuation.amount - holdings_for_date.sum(&:amount) + else + transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } + + calculate_balance(prior_balance, transactions) + end + + balance_record = Account::Balance.new( + account: account, + date: date, + balance: valuation ? current_balance : prior_balance, + cash_balance: valuation ? current_balance : prior_balance, + currency: account.currency + ) + + prior_balance = current_balance + + balance_record + end + end + + def forward_cash_balances + prior_balance = 0 + current_balance = nil + + oldest_date.upto(Date.current).map do |date| + entries_for_date = converted_entries.select { |e| e.date == date } + holdings_for_date = converted_holdings.select { |h| h.date == date } + + valuation = entries_for_date.find { |e| e.account_valuation? } + + current_balance = if valuation + # To get this to a cash valuation, we back out holdings value on day + valuation.amount - holdings_for_date.sum(&:amount) + else + transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } + + calculate_balance(prior_balance, transactions, inverse: true) + end + + balance_record = Account::Balance.new( + account: account, + date: date, + balance: current_balance, + cash_balance: current_balance, + currency: account.currency + ) + + prior_balance = current_balance + + balance_record + end + end + + def converted_entries + @converted_entries ||= @account.entries.order(:date).to_a.map do |e| + converted_entry = e.dup + converted_entry.amount = converted_entry.amount_money.exchange_to( + account.currency, + date: e.date, + fallback_rate: 1 + ).amount + converted_entry.currency = account.currency + converted_entry + end + end + + def converted_holdings + @converted_holdings ||= holdings.map do |h| + converted_holding = h.dup + converted_holding.amount = converted_holding.amount_money.exchange_to( + account.currency, + date: h.date, + fallback_rate: 1 + ).amount + converted_holding.currency = account.currency + converted_holding + end + end + + def calculate_balance(prior_balance, transactions, inverse: false) + flows = transactions.sum(&:amount) + negated = inverse ? account.asset? : account.liability? + flows *= -1 if negated + prior_balance + flows + end +end diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/account/balance_trend_calculator.rb new file mode 100644 index 00000000..a2e89e1d --- /dev/null +++ b/app/models/account/balance_trend_calculator.rb @@ -0,0 +1,94 @@ +# The current system calculates a single, end-of-day balance every day for each account for simplicity. +# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances +# to show users how each entry affects their balances. This class calculates intraday balances by +# interpolating between end-of-day balances. +class Account::BalanceTrendCalculator + BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) + + class << self + def for(entries) + return nil if entries.blank? + + account = entries.first.account + + date_range = entries.minmax_by(&:date) + min_entry_date, max_entry_date = date_range.map(&:date) + + # In case view is filtered and there are entry gaps, refetch all entries in range + all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a + balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a + holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a + + new(all_entries, balances, holdings) + end + end + + def initialize(entries, balances, holdings) + @entries = entries + @balances = balances + @holdings = holdings + end + + def trend_for(entry) + intraday_balance = nil + intraday_cash_balance = nil + + start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency } + end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency } + + return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank? + + todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount) + + prior_balance = start_of_day_balance.balance + prior_cash_balance = start_of_day_balance.cash_balance + current_balance = nil + current_cash_balance = nil + + todays_entries = entries.select { |e| e.date == entry.date } + + todays_entries.each_with_index do |e, idx| + if e.account_valuation? + current_balance = e.amount + current_cash_balance = e.amount + else + multiplier = e.account.liability? ? 1 : -1 + balance_change = e.account_trade? ? 0 : multiplier * e.amount + cash_change = multiplier * e.amount + + current_balance = prior_balance + balance_change + current_cash_balance = prior_cash_balance + cash_change + end + + if e.id == entry.id + # Final entry should always match the end-of-day balances + if idx == todays_entries.size - 1 + intraday_balance = end_of_day_balance.balance + intraday_cash_balance = end_of_day_balance.cash_balance + else + intraday_balance = current_balance + intraday_cash_balance = current_cash_balance + end + + break + else + prior_balance = current_balance + prior_cash_balance = current_cash_balance + end + end + + return BalanceTrend.new(trend: nil) unless intraday_balance.present? + + BalanceTrend.new( + trend: TimeSeries::Trend.new( + current: Money.new(intraday_balance, entry.currency), + previous: Money.new(prior_balance, entry.currency), + favorable_direction: entry.account.favorable_direction + ), + cash: Money.new(intraday_cash_balance, entry.currency), + ) + end + + private + attr_reader :entries, :balances, :holdings +end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 2addf3be..1801fb9e 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -14,9 +14,22 @@ class Account::Entry < ApplicationRecord validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } - scope :chronological, -> { order(:date, :created_at) } - scope :not_account_valuations, -> { where.not(entryable_type: "Account::Valuation") } - scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } + scope :chronological, -> { + order( + date: :asc, + Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + created_at: :asc + ) + } + + scope :reverse_chronological, -> { + order( + date: :desc, + Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + created_at: :desc + ) + } + scope :without_transfers, -> { where(marked_as_transfer: false) } scope :with_converted_amount, ->(currency) { # Join with exchange rates to convert the amount to the given currency @@ -30,12 +43,7 @@ class Account::Entry < ApplicationRecord } def sync_account_later - sync_start_date = if destroyed? - previous_entry&.date - else - [ date_previously_was, date ].compact.min - end - + sync_start_date = [ date_previously_was, date ].compact.min unless destroyed? account.sync_later(start_date: sync_start_date) end @@ -51,45 +59,8 @@ class Account::Entry < ApplicationRecord entryable_type.demodulize.underscore end - def prior_balance - account.balances.find_by(date: date - 1)&.balance || 0 - end - - def prior_entry_balance - entries_on_entry_date - .not_account_valuations - .last - &.balance_after_entry || 0 - end - - def balance_after_entry - if account_valuation? - Money.new(amount, currency) - else - new_balance = prior_balance - entries_on_entry_date.each do |e| - next if e.account_valuation? - - change = e.amount - change = account.liability? ? change : -change - new_balance += change - break if e == self - end - - Money.new(new_balance, currency) - end - end - - def trend - TimeSeries::Trend.new( - current: balance_after_entry, - previous: Money.new(prior_entry_balance, currency), - favorable_direction: account.favorable_direction - ) - end - - def entries_on_entry_date - account.entries.where(date: date).order(created_at: :asc) + def balance_trend(entries, balances) + Account::BalanceTrendCalculator.new(self, entries, balances).trend end class << self @@ -233,15 +204,4 @@ class Account::Entry < ApplicationRecord entryable_ids end end - - private - - def previous_entry - @previous_entry ||= account - .entries - .where("date < ?", date) - .where("entryable_type = ?", entryable_type) - .order(date: :desc) - .first - end end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index 819cd5b2..432ec2de 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -22,9 +22,9 @@ class Account::Holding < ApplicationRecord def weight return nil unless amount + return 0 if amount.zero? - portfolio_value = account.holdings.current.known_value.sum(&:amount) - portfolio_value.zero? ? 1 : amount / portfolio_value * 100 + account.balance.zero? ? 1 : amount / account.balance * 100 end # Basic approximation of cost-basis diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb deleted file mode 100644 index de5462fa..00000000 --- a/app/models/account/holding/syncer.rb +++ /dev/null @@ -1,136 +0,0 @@ -class Account::Holding::Syncer - def initialize(account, start_date: nil) - @account = account - end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current - @sync_date_range = calculate_sync_start_date(start_date)..end_date - @portfolio = {} - - load_prior_portfolio if start_date - end - - def run - holdings = [] - - sync_date_range.each do |date| - holdings += build_holdings_for_date(date) - end - - upsert_holdings holdings - end - - private - - attr_reader :account, :sync_date_range - - def sync_entries - @sync_entries ||= account.entries - .account_trades - .includes(entryable: :security) - .where("date >= ?", sync_date_range.begin) - .order(:date) - end - - def get_cached_price(ticker, date) - return nil unless security_prices.key?(ticker) - - price = security_prices[ticker].find { |p| p.date == date } - price ? price[:price] : nil - end - - def security_prices - @security_prices ||= begin - prices = {} - ticker_securities = {} - - sync_entries.each do |entry| - security = entry.account_trade.security - unless ticker_securities[security.ticker] - ticker_securities[security.ticker] = { - security: security, - start_date: entry.date - } - end - end - - ticker_securities.each do |ticker, data| - fetched_prices = Security::Price.find_prices( - security: data[:security], - start_date: data[:start_date], - end_date: Date.current - ) - gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run - prices[ticker] = gapfilled_prices - end - - prices - end - end - - def build_holdings_for_date(date) - trades = sync_entries.select { |trade| trade.date == date } - - @portfolio = generate_next_portfolio(@portfolio, trades) - - @portfolio.map do |ticker, holding| - trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] } - trade_price = trade&.account_trade&.price - - price = get_cached_price(ticker, date) || trade_price - - account.holdings.build \ - date: date, - security_id: holding[:security_id], - qty: holding[:qty], - price: price, - amount: price ? (price * holding[:qty]) : nil, - currency: holding[:currency] - end - end - - def generate_next_portfolio(prior_portfolio, trade_entries) - trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio| - trade = entry.account_trade - - price = trade.price - prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0 - new_qty = prior_qty + trade.qty - - new_portfolio[trade.security.ticker] = { - qty: new_qty, - price: price, - amount: new_qty * price, - currency: entry.currency, - security_id: trade.security_id - } - end - end - - def upsert_holdings(holdings) - current_time = Time.now - holdings_to_upsert = holdings.map do |holding| - holding.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") - .merge("updated_at" => current_time) - end - - account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency]) - end - - def load_prior_portfolio - prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day) - - prior_day_holdings.each do |holding| - @portfolio[holding.security.ticker] = { - qty: holding.qty, - price: holding.price, - amount: holding.amount, - currency: holding.currency, - security_id: holding.security_id - } - end - end - - def calculate_sync_start_date(start_date) - start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current - end -end diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb new file mode 100644 index 00000000..815e3fdf --- /dev/null +++ b/app/models/account/holding_calculator.rb @@ -0,0 +1,154 @@ +class Account::HoldingCalculator + def initialize(account) + @account = account + @securities_cache = {} + end + + def calculate(reverse: false) + preload_securities + calculated_holdings = reverse ? reverse_holdings : forward_holdings + gapfill_holdings(calculated_holdings) + end + + private + attr_reader :account, :securities_cache + + def reverse_holdings + current_holding_quantities = load_current_holding_quantities + prior_holding_quantities = {} + + holdings = [] + + Date.current.downto(portfolio_start_date).map do |date| + today_trades = trades.select { |t| t.date == date } + prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades) + holdings += generate_holding_records(current_holding_quantities, date) + current_holding_quantities = prior_holding_quantities + end + + holdings + end + + def forward_holdings + prior_holding_quantities = load_empty_holding_quantities + current_holding_quantities = {} + + holdings = [] + + portfolio_start_date.upto(Date.current).map do |date| + today_trades = trades.select { |t| t.date == date } + current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true) + holdings += generate_holding_records(current_holding_quantities, date) + prior_holding_quantities = current_holding_quantities + end + + holdings + end + + def generate_holding_records(portfolio, date) + portfolio.map do |security_id, qty| + security = securities_cache[security_id] + price = security.dig(:prices)&.find { |p| p.date == date } + + next if price.blank? + + account.holdings.build( + security: security.dig(:security), + date: date, + qty: qty, + price: price.price, + currency: price.currency, + amount: qty * price.price + ) + end.compact + end + + def gapfill_holdings(holdings) + filled_holdings = [] + + holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings| + next if security_holdings.empty? + + sorted = security_holdings.sort_by(&:date) + previous_holding = sorted.first + + sorted.first.date.upto(Date.current) do |date| + holding = security_holdings.find { |h| h.date == date } + + if holding + filled_holdings << holding + previous_holding = holding + else + # Create a new holding based on the previous day's data + filled_holdings << account.holdings.build( + security: previous_holding.security, + date: date, + qty: previous_holding.qty, + price: previous_holding.price, + currency: previous_holding.currency, + amount: previous_holding.amount + ) + end + end + end + + filled_holdings + end + + def trades + @trades ||= account.entries.includes(entryable: :security).account_trades.to_a + end + + def portfolio_start_date + trades.first ? trades.first.date - 1.day : Date.current + end + + def preload_securities + securities = trades.map(&:entryable).map(&:security).uniq + + securities.each do |security| + prices = Security::Price.find_prices( + security: security, + start_date: portfolio_start_date, + end_date: Date.current + ) + + @securities_cache[security.id] = { + security: security, + prices: prices + } + end + end + + def calculate_portfolio(holding_quantities, today_trades, inverse: false) + new_quantities = holding_quantities.dup + + today_trades.each do |trade| + security_id = trade.entryable.security_id + qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty + new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change + end + + new_quantities + end + + def load_empty_holding_quantities + holding_quantities = {} + + trades.map { |t| t.entryable.security_id }.uniq.each do |security_id| + holding_quantities[security_id] = 0 + end + + holding_quantities + end + + def load_current_holding_quantities + holding_quantities = load_empty_holding_quantities + + account.holdings.where(date: Date.current).map do |holding| + holding_quantities[holding.security_id] = holding.qty + end + + holding_quantities + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb new file mode 100644 index 00000000..43ac980e --- /dev/null +++ b/app/models/account/syncer.rb @@ -0,0 +1,104 @@ +class Account::Syncer + def initialize(account, start_date: nil) + @account = account + @start_date = start_date + end + + def run + holdings = sync_holdings + balances = sync_balances(holdings) + update_account_info(balances, holdings) unless account.plaid_account_id.present? + convert_foreign_records(balances) + end + + private + attr_reader :account, :start_date + + def account_start_date + @account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day + end + + def update_account_info(balances, holdings) + new_balance = balances.sort_by(&:date).last.balance + new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount) + new_cash_balance = new_balance - new_holdings_value + + account.update!( + balance: new_balance, + cash_balance: new_cash_balance + ) + end + + def sync_holdings + calculator = Account::HoldingCalculator.new(account) + calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?) + + current_time = Time.now + + Account.transaction do + account.holdings.upsert_all( + calculated_holdings.map { |h| h.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id security_id date currency] + ) if calculated_holdings.any? + + # Purge outdated holdings + account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id)) + end + + calculated_holdings + end + + def sync_balances(holdings) + calculator = Account::BalanceCalculator.new(account, holdings: holdings) + calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date) + + Account.transaction do + load_balances(calculated_balances) + + # Purge outdated balances + account.balances.delete_by("date < ?", account_start_date) + end + + calculated_balances + end + + def convert_foreign_records(balances) + converted_balances = convert_balances(balances) + load_balances(converted_balances) + end + + def load_balances(balances) + current_time = Time.now + account.balances.upsert_all( + balances.map { |b| b.attributes + .slice("date", "balance", "cash_balance", "currency") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id date currency] + ) if balances.any? + end + + def convert_balances(balances) + return [] if account.currency == account.family.currency + + from_currency = account.currency + to_currency = account.family.currency + + exchange_rates = ExchangeRate.find_rates( + from: from_currency, + to: to_currency, + start_date: balances.first.date + ) + + balances.map do |balance| + exchange_rate = exchange_rates.find { |er| er.date == balance.date } + + account.balances.build( + date: balance.date, + balance: exchange_rate.rate * balance.balance, + currency: to_currency + ) if exchange_rate.present? + end + end +end diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index dd6b966c..191d8100 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -59,7 +59,7 @@ class Account::TradeBuilder ) else account.entries.build( - name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}", + name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}", date: date, amount: signed_amount, currency: currency, diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index 653c11e2..93ebf5ff 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -10,44 +10,4 @@ class Account::Valuation < ApplicationRecord false end end - - def name - entry.name || (oldest? ? "Initial balance" : "Balance update") - end - - def trend - @trend ||= create_trend - end - - def icon - oldest? ? "plus" : entry.trend.icon - end - - def color - oldest? ? "#D444F1" : entry.trend.color - end - - private - def oldest? - @oldest ||= account.entries.where("date < ?", entry.date).empty? - end - - def account - @account ||= entry.account - end - - def create_trend - TimeSeries::Trend.new( - current: entry.amount_money, - previous: prior_balance&.balance_money, - favorable_direction: account.favorable_direction - ) - end - - def prior_balance - @prior_balance ||= account.balances - .where("date < ?", entry.date) - .order(date: :desc) - .first - end end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 6c93a8f8..73e5ef69 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -18,26 +18,7 @@ module Accountable has_one :account, as: :accountable, touch: true end - def value - account.balance_money - end - - def series(period: Period.all, currency: account.currency) - balance_series = account.balances.in_period(period).where(currency: currency) - - if balance_series.empty? && period.date_range.end == Date.current - TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ]) - else - TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: account.asset? ? "up" : "down") - end - rescue Money::ConversionError - TimeSeries.new([]) - end - def post_sync - broadcast_remove_to(account.family, target: "syncing-notice") - - # Broadcast a simple replace event that the controller can handle broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/gapfiller.rb b/app/models/gapfiller.rb deleted file mode 100644 index f4f050f9..00000000 --- a/app/models/gapfiller.rb +++ /dev/null @@ -1,48 +0,0 @@ -class Gapfiller - attr_reader :series - - def initialize(series, start_date:, end_date:, cache:) - @series = series - @date_range = start_date..end_date - @cache = cache - end - - def run - gapfilled_records = [] - - date_range.each do |date| - record = series.find { |r| r.date == date } - - if should_gapfill?(date, record) - prev_record = gapfilled_records.find { |r| r.date == date - 1.day } - - if prev_record - new_record = create_gapfilled_record(prev_record, date) - gapfilled_records << new_record - end - else - gapfilled_records << record if record - end - end - - gapfilled_records - end - - private - attr_reader :date_range, :cache - - def should_gapfill?(date, record) - (date.on_weekend? || holiday?(date)) && record.nil? - end - - def holiday?(date) - Holidays.on(date, :federalreserve, :us, :observed, :informal).any? - end - - def create_gapfilled_record(prev_record, date) - new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at")) - new_record.date = date - new_record.save! if cache - new_record - end -end diff --git a/app/models/investment.rb b/app/models/investment.rb index 90519da3..29148f06 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -16,37 +16,6 @@ class Investment < ApplicationRecord [ "Angel", "angel" ] ].freeze - def value - account.balance_money + holdings_value - end - - def holdings_value - account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency) - end - - def series(period: Period.all, currency: account.currency) - balance_series = account.balances.in_period(period).where(currency: currency) - holding_series = account.holdings.known_value.in_period(period).where(currency: currency) - - holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings| - holdings.sum(&:amount) - end - - combined_series = balance_series.map do |balance| - holding_amount = holdings_by_date[balance.date] || 0 - - { date: balance.date, value: Money.new(balance.balance + holding_amount, currency) } - end - - if combined_series.empty? && period.date_range.end == Date.current - TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ]) - else - TimeSeries.new(combined_series) - end - rescue Money::ConversionError - TimeSeries.new([]) - end - def color "#1570EF" end @@ -56,8 +25,6 @@ class Investment < ApplicationRecord end def post_sync - broadcast_remove_to(account, target: "syncing-notice") - broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 240b7c18..5a6d03e7 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -167,6 +167,7 @@ class PlaidAccount < ApplicationRecord end return nil if security.nil? || security.ticker_symbol.blank? + return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately Security.find_or_create_by!( ticker: security.ticker_symbol, diff --git a/app/views/account/cashes/_cash.html.erb b/app/views/account/cashes/_cash.html.erb deleted file mode 100644 index e5e2065d..00000000 --- a/app/views/account/cashes/_cash.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%# locals: (holding:) %> - -<%= turbo_frame_tag dom_id(holding) do %> -
-
- <%= render "shared/circle_logo", name: holding.name %> -
- <%= tag.p holding.name %> - <%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %> -
-
- -
- <% if holding.amount_money %> - <%= tag.p format_money holding.amount_money %> - <% else %> - <%= tag.p "?", class: "text-gray-500" %> - <% end %> -
-
-<% end %> diff --git a/app/views/account/cashes/index.html.erb b/app/views/account/cashes/index.html.erb deleted file mode 100644 index 1d0b8c82..00000000 --- a/app/views/account/cashes/index.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%= turbo_frame_tag dom_id(@account, "cash") do %> -
-
- <%= tag.h2 t(".cash"), class: "font-medium text-lg" %> -
- -
-
- <%= tag.p t(".name"), class: "col-span-9" %> - <%= tag.p t(".value"), class: "col-span-3 justify-self-end" %> -
- -
- <%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %> -
-
-
-<% end %> diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb index 9bfe063a..0007f2c6 100644 --- a/app/views/account/entries/_entry.html.erb +++ b/app/views/account/entries/_entry.html.erb @@ -1,5 +1,5 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <%= turbo_frame_tag dom_id(entry) do %> - <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %> + <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, balance_trend: } %> <% end %> diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 1ce47409..c659d97f 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -73,13 +73,14 @@
-
+ <% calculator = Account::BalanceTrendCalculator.for(@entries) %> <%= entries_by_date(@entries) do |entries| %> - <%= render entries, show_balance: true %> + <% entries.each do |entry| %> + <%= render entry, balance_trend: calculator&.trend_for(entry) %> + <% end %> <% end %>
-
diff --git a/app/views/account/holdings/_cash.html.erb b/app/views/account/holdings/_cash.html.erb new file mode 100644 index 00000000..cc135a06 --- /dev/null +++ b/app/views/account/holdings/_cash.html.erb @@ -0,0 +1,32 @@ +<%# locals: (account:) %> + +<% currency = Money::Currency.new(account.currency) %> + +
+
+ <%= render "shared/circle_logo", name: currency.iso_code %> + +
+ <%= tag.p t(".brokerage_cash"), class: "text-gray-900" %> + <%= tag.p account.currency, class: "text-gray-500 text-xs uppercase" %> +
+
+ +
+ <% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> + <%= render "shared/progress_circle", progress: cash_weight, text_class: "text-blue-500" %> + <%= tag.p number_to_percentage(cash_weight, precision: 1) %> +
+ +
+ <%= tag.p "--", class: "text-gray-500" %> +
+ +
+ <%= tag.p format_money account.cash_balance %> +
+ +
+ <%= tag.p "--", class: "text-gray-500" %> +
+
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index af87088b..8e96fa95 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -2,7 +2,7 @@
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %> - <%= link_to new_account_trade_path(@account), + <%= link_to new_account_trade_path(account_id: @account.id), id: dom_id(@account, "new_trade"), data: { turbo_frame: :modal }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> @@ -21,8 +21,10 @@
- <% if @holdings.any? %> - <%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %> + <% if @account.holdings.current.any? %> + <%= render "account/holdings/cash", account: @account %> + <%= render "account/holdings/ruler" %> + <%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %> <% else %>

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

<% end %> diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index fbb288df..aaaf6900 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -32,7 +32,7 @@
<% end %> - <%= form.date_field :date, label: true, value: Date.today, required: true %> + <%= form.date_field :date, label: true, value: Date.current, required: true %> <% unless %w[buy sell].include?(type) %> <%= form.money_field :amount, label: t(".amount"), required: true %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index bd91d147..2215cd94 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <% trade, account = entry.account_trade, entry.account %> @@ -37,6 +37,12 @@
- <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% if balance_trend&.trend %> +
+ <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %> +
+ <% else %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% end %>
diff --git a/app/views/account/trades/show.html.erb b/app/views/account/trades/show.html.erb index 1a1d8cf6..affd2952 100644 --- a/app/views/account/trades/show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -13,7 +13,7 @@ data: { controller: "auto-submit-form" } do |f| %> <%= f.date_field :date, label: t(".date_label"), - max: Date.today, + max: Date.current, "data-auto-submit-form-target": "auto" %>
diff --git a/app/views/account/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb index 10c09d30..47a0f01c 100644 --- a/app/views/account/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -28,7 +28,7 @@ <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <% end %> - <%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today, value: Date.today %> + <%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 9a300440..dc2a026a 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <% transaction, account = entry.account_transaction, entry.account %>
text-sm font-medium p-4"> @@ -34,7 +34,7 @@
<% if entry.transfer.present? %> - <% unless show_balance %> + <% unless balance_trend %>
<% end %> @@ -46,7 +46,7 @@ <%= render "categories/menu", transaction: transaction %>
- <% unless show_balance %> + <% unless balance_trend %> <%= tag.div class: "col-span-2 overflow-hidden truncate" do %> <% if entry.new_record? %> <%= tag.p account.name %> @@ -66,12 +66,12 @@ class: ["text-green-600": entry.inflow?] %>
- <% if show_balance %> + <% if balance_trend %>
- <% if entry.account.investment? %> - <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% if balance_trend.trend %> + <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %> <% else %> - <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> <% end %>
<% end %> diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 0124d3ea..b55544a7 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -29,7 +29,7 @@ <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> - <%= f.date_field :date, value: transfer.date || Date.today, label: t(".date"), required: true, max: Date.current %> + <%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index 68345c73..b56e847b 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -8,7 +8,7 @@ <% end %>
- <%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %> + <%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %> <%= form.money_field :amount, label: t(".amount"), required: true %>
diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index b3e9caf4..fc4c05d0 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -1,7 +1,7 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> -<% account = entry.account %> -<% valuation = entry.account_valuation %> +<% color = balance_trend&.trend&.color || "#D444F1" %> +<% icon = balance_trend&.trend&.icon || "plus" %>
@@ -12,15 +12,15 @@ <% end %>
- <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(valuation.color) do %> - <%= lucide_icon valuation.icon, class: "w-4 h-4" %> + <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> + <%= lucide_icon icon, class: "w-4 h-4" %> <% end %>
<% if entry.new_record? %> <%= content_tag :p, entry.name %> <% else %> - <%= link_to valuation.name, + <%= link_to entry.name || t(".balance_update"), account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> @@ -29,8 +29,12 @@
-
- <%= tag.span format_money(entry.trend.value) %> +
+ <% if balance_trend&.trend %> + <%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %> + <% else %> + <%= tag.span "--", class: "text-gray-400" %> + <% end %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 7546748d..168182fd 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -11,7 +11,7 @@ <%= tooltip %>
- <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> + <%= tag.p format_money(account.balance_money), class: "text-gray-900 text-3xl font-medium" %>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/investments/_cash_tab.html.erb b/app/views/investments/_cash_tab.html.erb deleted file mode 100644 index 2ebd3126..00000000 --- a/app/views/investments/_cash_tab.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%# locals: (account:) %> - -<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %> - <%= render "account/entries/loading" %> -<% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb deleted file mode 100644 index 9ac9868d..00000000 --- a/app/views/investments/_chart.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%# locals: (account:, **args) %> - -
-
-
-
- <%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %> -
- - <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> -
-
- -
- <%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %> -
-

Historical investment data coming soon.

-

We're working to bring you the full picture.

-
-
-
diff --git a/app/views/investments/_value_tooltip.html.erb b/app/views/investments/_value_tooltip.html.erb index c624afbc..be275222 100644 --- a/app/views/investments/_value_tooltip.html.erb +++ b/app/views/investments/_value_tooltip.html.erb @@ -1,4 +1,4 @@ -<%# locals: (value:, cash:) %> +<%# locals: (balance:, holdings:, cash:) %>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %> @@ -7,14 +7,6 @@ <%= t(".total_value_tooltip") %>
-
- <%= t(".holdings") %> -
-
- <%= tag.p format_money(value, precision: 0) %> -
-
-
<%= t(".cash") %>
@@ -22,5 +14,24 @@ <%= tag.p format_money(cash, precision: 0) %>
+
+
+ <%= t(".holdings") %> +
+
+ <%= tag.p format_money(holdings, precision: 0) %> +
+
+ +
+ +
+
+ <%= t(".total") %> +
+
+ <%= tag.p format_money(balance, precision: 0) %> +
+
diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 36f1b934..bc271e68 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -4,24 +4,20 @@ <%= tag.div class: "space-y-4" do %> <%= render "accounts/show/header", account: @account %> - <% if @account.plaid_account_id.present? %> - <%= render "investments/chart", account: @account %> - <% else %> - <%= render "accounts/show/chart", + <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), tooltip: render( "investments/value_tooltip", - value: @account.value, - cash: @account.balance_money + balance: @account.balance_money, + holdings: @account.balance - @account.cash_balance, + cash: @account.cash_balance ) %> - <% end %>
<%= render "accounts/show/tabs", account: @account, tabs: [ - { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, - { key: "cash", contents: render("investments/cash_tab", account: @account) } + { key: "activity", contents: render("accounts/show/activity", account: @account) }, ] %>
<% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index b0dfcde7..225e5d96 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -20,6 +20,11 @@ { label: t(".language") }, { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :timezone, + timezone_options, + { label: t(".timezone") }, + { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :date_format, date_format_options, { label: t(".date_format") }, diff --git a/app/views/shared/_progress_circle.html.erb b/app/views/shared/_progress_circle.html.erb index 3f7ec528..a3529193 100644 --- a/app/views/shared/_progress_circle.html.erb +++ b/app/views/shared/_progress_circle.html.erb @@ -1,10 +1,31 @@ <%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %> -<% circumference = Math::PI * 2 * radius %> -<% progress_percent = progress.clamp(0, 100) %> -<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %> + +<% + circumference = Math::PI * 2 * radius + progress_percent = progress.clamp(0, 100) + stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 + center = radius + stroke / 2 +%> + - + + - - + + diff --git a/config/locales/defaults/et.yml b/config/locales/defaults/et.yml index 337b5a87..6b0e17c6 100644 --- a/config/locales/defaults/et.yml +++ b/config/locales/defaults/et.yml @@ -13,7 +13,7 @@ et: - E - T - K - - N + - "N" - R - L abbr_month_names: diff --git a/config/locales/views/account/cashes/en.yml b/config/locales/views/account/cashes/en.yml deleted file mode 100644 index 96da5309..00000000 --- a/config/locales/views/account/cashes/en.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -en: - account: - cashes: - index: - cash: Cash - name: Name - value: Total Balance diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index b2e3fe25..08a1baf3 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -2,6 +2,8 @@ en: account: holdings: + cash: + brokerage_cash: Brokerage cash destroy: success: Holding deleted holding: diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index c157b6d6..ba8637c6 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -2,6 +2,8 @@ en: account: valuations: + valuation: + balance_update: Balance update form: amount: Amount submit: Add balance update diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 8e7359f5..3180b37e 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -6,6 +6,8 @@ en: troubleshoot: Troubleshoot account_list: new_account: New %{type} + chart: + no_change: no change create: success: "%{type} account created" destroy: @@ -31,8 +33,6 @@ en: manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? - chart: - no_change: no change show: chart: balance: Balance diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index ec2cbe86..451609e3 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,8 +1,6 @@ --- en: investments: - chart: - value: Total value edit: edit: Edit %{account} form: @@ -15,5 +13,6 @@ en: value_tooltip: cash: Cash holdings: Holdings - total_value_tooltip: The total value is the sum of cash balance and your holdings - value, minus margin loans. + total: Portfolio balance + total_value_tooltip: The total portfolio balance is the sum of brokerage cash + (available for trading) and the current market value of your holdings. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 9d8532e2..aa8141b8 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -39,6 +39,7 @@ en: theme_subtitle: Choose a preferred theme for the app (coming soon...) theme_system: System theme_title: Theme + timezone: Timezone profiles: show: confirm_delete: diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 0e020f42..5862d7c0 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -1,8 +1,6 @@ --- en: shared: - syncing_notice: - syncing: Syncing accounts data... confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

" @@ -15,6 +13,8 @@ en: no_account_subtitle: Since no accounts have been added, there's no data to display. Add your first accounts to start viewing dashboard data. no_account_title: No accounts yet + syncing_notice: + syncing: Syncing accounts data... upgrade_notification: app_upgraded: The app has been upgraded to %{version}. dismiss: Dismiss diff --git a/config/routes.rb b/config/routes.rb index c7c14d91..de480f35 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,7 +75,6 @@ Rails.application.routes.draw do namespace :account do resources :holdings, only: %i[index new show destroy] - resources :cashes, only: :index resources :entries, only: :index diff --git a/db/migrate/20241204235400_add_balance_components.rb b/db/migrate/20241204235400_add_balance_components.rb new file mode 100644 index 00000000..4b5dd206 --- /dev/null +++ b/db/migrate/20241204235400_add_balance_components.rb @@ -0,0 +1,6 @@ +class AddBalanceComponents < ActiveRecord::Migration[7.2] + def change + add_column :accounts, :cash_balance, :decimal, precision: 19, scale: 4, default: 0 + add_column :account_balances, :cash_balance, :decimal, precision: 19, scale: 4, default: 0 + end +end diff --git a/db/migrate/20241207002408_add_family_timezone.rb b/db/migrate/20241207002408_add_family_timezone.rb new file mode 100644 index 00000000..b8d3fb76 --- /dev/null +++ b/db/migrate/20241207002408_add_family_timezone.rb @@ -0,0 +1,5 @@ +class AddFamilyTimezone < ActiveRecord::Migration[7.2] + def change + add_column :families, :timezone, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 945f18b1..31183733 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: 2024_11_26_211249) do +ActiveRecord::Schema[7.2].define(version: 2024_12_07_002408) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -27,6 +27,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.string "currency", default: "USD", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id"], name: "index_account_balances_on_account_id" end @@ -112,6 +113,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false t.datetime "last_synced_at" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -218,6 +220,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" t.datetime "last_synced_at" + t.string "timezone" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 1bb48c6e..810dbf41 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -43,6 +43,7 @@ investment: family: dylan_family name: Robinhood Brokerage Account balance: 10000 + cash_balance: 5000 currency: USD accountable_type: Investment accountable: one diff --git a/test/fixtures/investments.yml b/test/fixtures/investments.yml index e0553ab0..386cd707 100644 --- a/test/fixtures/investments.yml +++ b/test/fixtures/investments.yml @@ -1 +1 @@ -one: { } \ No newline at end of file +one: {} \ No newline at end of file diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb deleted file mode 100644 index 528863be..00000000 --- a/test/models/account/balance/syncer_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -require "test_helper" - -class Account::Balance::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper - - setup do - @account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new) - @investment_account = families(:empty).accounts.create!(name: "Test Investment", balance: 50000, currency: "USD", accountable: Investment.new) - end - - test "syncs account with no entries" do - assert_equal 0, @account.balances.count - - run_sync_for @account - - assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations only" do - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000) - - run_sync_for @account - - assert_equal 22000, @account.balance - assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with transactions only" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100) - create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500) - - run_sync_for @account - - assert_equal 20000, @account.balance - assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations and transactions when valuation starts" do - create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000) - create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) - create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000) - - run_sync_for(@account) - - assert_equal 25000, @account.balance - assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations and transactions when transaction starts" do - new_account = families(:empty).accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new) - create_transaction(account: new_account, date: 2.days.ago.to_date, amount: 250) - create_valuation(account: new_account, date: Date.current, amount: 1000) - - run_sync_for(new_account) - - assert_equal 1000, new_account.balance - assert_equal [ 1250, 1000, 1000, 1000 ], new_account.balances.chronological.map(&:balance) - end - - test "syncs account with transactions in multiple currencies" do - ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 - - create_transaction(account: @account, date: 3.days.ago.to_date, amount: 100, currency: "USD") - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD") - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600 - - run_sync_for(@account) - - assert_equal 20000, @account.balance - assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance) - end - - test "converts foreign account balances to family currency" do - @account.update! currency: "EUR" - - create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR") - - create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2) - create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2) - create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2) - - with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do - run_sync_for(@account) - end - - usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance) - eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance) - - assert_equal 20000, @account.balance - assert_equal [ 21000, 20000, 20000 ], eur_balances # native account balances - assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1 - end - - test "raises issue if missing exchange rates" do - create_transaction(date: Date.current, account: @account, currency: "EUR") - - ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil) - @account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ]) - - syncer = Account::Balance::Syncer.new(@account) - - syncer.run - end - - # Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but - # doesn't have exchange rates available to convert those calculated balances to the family currency - test "observes issue if exchange rate provider is not configured" do - @account.update! currency: "EUR" - - syncer = Account::Balance::Syncer.new(@account) - - @account.expects(:observe_missing_exchange_rate_provider) - - with_env_overrides SYNTH_API_KEY: nil do - syncer.run - end - end - - test "overwrites existing balances and purges stale balances" do - assert_equal 0, @account.balances.size - - @account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated - @account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted - - assert_equal 2, @account.balances.size - - run_sync_for(@account) - - assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) - end - - test "partial sync does not affect balances prior to sync start date" do - existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000 - - transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD") - - run_sync_for(@account, start_date: 1.day.ago.to_date) - - assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance) - end - - private - - def run_sync_for(account, start_date: nil) - syncer = Account::Balance::Syncer.new(account, start_date: start_date) - syncer.run - end - - def create_exchange_rate(date, from:, to:, rate:) - ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate - end -end diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb new file mode 100644 index 00000000..334a6478 --- /dev/null +++ b/test/models/account/balance_calculator_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class Account::BalanceCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + # When syncing backwards, we start with the account balance and generate everything from there. + test "reverse no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ @account.balance ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) + + assert_equal expected, calculated.map(&:balance) + end + + # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. + test "forward no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ 0 ] + calculated = Account::BalanceCalculator.new(@account).calculate + + assert_equal expected, calculated.map(&:balance) + end + + test "forward valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 0, 17000, 17000, 19000, 19000, 19000 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "forward transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 0, 500, 500, 400, 400, 400 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "forward multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "investment balance sync" do + @account.update!(cash_balance: 18000) + + # Transactions represent deposits / withdrawals from the brokerage account + # Ex: We deposit $20,000 into the brokerage account + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000) + + # Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings. + # Ex: We buy 20 shares of MSFT at $100 for a total of $2000 + create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100) + + holdings = [ + Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000), + Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000), + Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0) + ] + + expected = [ 0, 20000, 20000, 20000 ] + calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance) + calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance) + + assert_equal calculated_forwards, calculated_backwards + assert_equal expected, calculated_forwards + end + + test "multi-currency sync" do + ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 + + create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD") + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD") + + # Transaction in different currency than the account's main currency + create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 + + expected = [ 0, 100, 400, 1000, 1000 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + private + def create_holding(date:, security:, amount:) + Account::Holding.create!( + account: @account, + security: security, + date: date, + qty: 0, # not used + price: 0, # not used + amount: amount, + currency: @account.currency + ) + end +end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index b5711d29..9c541b6b 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -43,13 +43,10 @@ class Account::EntryTest < ActiveSupport::TestCase end test "triggers sync with correct start date when transaction deleted" do - current_entry = create_transaction(date: 1.day.ago.to_date) - prior_entry = create_transaction(date: current_entry.date - 1.day) + @entry.destroy! - current_entry.destroy! - - current_entry.account.expects(:sync_later).with(start_date: prior_entry.date) - current_entry.sync_account_later + @entry.account.expects(:sync_later).with(start_date: nil) + @entry.sync_account_later end test "can search entries" do @@ -99,26 +96,4 @@ class Account::EntryTest < ActiveSupport::TestCase assert create_transaction(amount: -10).inflow? assert create_transaction(amount: 10).outflow? end - - test "balance_after_entry skips account valuations" do - family = families(:empty) - account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new - - new_valuation = create_valuation(account: account, amount: 1) - transaction = create_transaction(date: new_valuation.date, account: account, amount: -100) - - - assert_equal Money.new(100), transaction.balance_after_entry - end - - test "prior_entry_balance returns last transaction entry balance" do - family = families(:empty) - account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new - - new_valuation = create_valuation(account: account, amount: 1) - transaction = create_transaction(date: new_valuation.date, account: account, amount: -100) - - - assert_equal Money.new(100), transaction.prior_entry_balance - end end diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb deleted file mode 100644 index 6d04fb3e..00000000 --- a/test/models/account/holding/syncer_test.rb +++ /dev/null @@ -1,145 +0,0 @@ -require "test_helper" - -class Account::Holding::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper, SecuritiesTestHelper - - setup do - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) - end - - test "account with no trades has no holdings" do - run_sync_for(@account) - - assert_equal [], @account.holdings - end - - test "can buy and sell securities" do - # First create securities with their prices - security1 = create_security("AMZN", prices: [ - { date: 2.days.ago.to_date, price: 214 }, - { date: 1.day.ago.to_date, price: 215 }, - { date: Date.current, price: 216 } - ]) - - security2 = create_security("NVDA", prices: [ - { date: 1.day.ago.to_date, price: 122 }, - { date: Date.current, price: 124 } - ]) - - # Then create trades after prices exist - create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN - create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN - create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA - create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN - - expected = [ - { security: security1, qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, - { security: security1, qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date }, - { security: security1, qty: 2, price: 216, amount: 2 * 216, date: Date.current }, - { security: security2, qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date }, - { security: security2, qty: 20, price: 124, amount: 20 * 124, date: Date.current } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - test "generates holdings with prices" do - provider = mock - Security::Price.stubs(:security_prices_provider).returns(provider) - - provider.expects(:fetch_security_prices).never - - amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ]) - create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215) - - expected = [ - { security: amzn, qty: 10, price: 215, amount: 10 * 215, date: Date.current } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - test "generates all holdings even when missing security prices" do - amzn = create_security("AMZN", prices: []) - - create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210) - - # 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price - # 1 day ago — finds daily price, uses it - # Today — no daily price, no entry, so price and amount are `nil` - expected = [ - { security: amzn, qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, - { security: amzn, qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date }, - { security: amzn, qty: 10, price: nil, amount: nil, date: Date.current } - ] - - fetched_prices = [ Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) ] - - Gapfiller.any_instance.expects(:run).returns(fetched_prices) - Security::Price.expects(:find_prices) - .with(security: amzn, start_date: 2.days.ago.to_date, end_date: Date.current) - .once - .returns(fetched_prices) - - run_sync_for(@account) - - assert_holdings(expected) - end - - # It is common for data providers to not provide prices for weekends, so we need to carry the last observation forward - test "uses locf gapfilling when price is missing" do - friday = Date.new(2024, 9, 27) # A known Friday - saturday = friday + 1.day # weekend - sunday = saturday + 1.day # weekend - monday = sunday + 1.day # known Monday - - # Prices should be gapfilled like this: 210, 210, 210, 220 - tm = create_security("TM", prices: [ - { date: friday, price: 210 }, - { date: monday, price: 220 } - ]) - - create_trade(tm, account: @account, qty: 10, date: friday, price: 210) - - expected = [ - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: friday }, - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: saturday }, - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: sunday }, - { security: tm, qty: 10, price: 220, amount: 10 * 220, date: monday } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - private - - def assert_holdings(expected_holdings) - holdings = @account.holdings.includes(:security).to_a - expected_holdings.each do |expected_holding| - actual_holding = holdings.find { |holding| - holding.security == expected_holding[:security] && - holding.date == expected_holding[:date] - } - date = expected_holding[:date] - expected_price = expected_holding[:price] - expected_qty = expected_holding[:qty] - expected_amount = expected_holding[:amount] - ticker = expected_holding[:security].ticker - - assert actual_holding, "expected #{ticker} holding on date: #{date}" - assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}" - assert_equal expected_holding[:amount].to_d, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}" - assert_equal expected_holding[:price].to_d, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}" - end - end - - def run_sync_for(account) - Account::Holding::Syncer.new(account).run - end -end diff --git a/test/models/account/holding_calculator_test.rb b/test/models/account/holding_calculator_test.rb new file mode 100644 index 00000000..154c8afe --- /dev/null +++ b/test/models/account/holding_calculator_test.rb @@ -0,0 +1,231 @@ +require "test_helper" + +class Account::HoldingCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "no holdings" do + forward = Account::HoldingCalculator.new(@account).calculate + reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true) + assert_equal forward, reverse + assert_equal [], forward + end + + # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings + test "reverse portfolio with trades but without current day holdings" do + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: Date.current, price: 470) + Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470) + + create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account) + + calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + assert_equal 2, calculated.length + end + + test "reverse portfolio calculation" do + load_today_portfolio + + # Build up to 10 shares of VOO (current value $5000) + create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) + create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) + create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) + + # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio + create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) + create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) + + # Build up to 100 shares of WMT (current value $10000) + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + # 4 days ago + Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), + Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 3 days ago + Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), + Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 2 days ago + Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), + + # 1 day ago + Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), + + # Today + Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) + ] + + calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + + assert_equal expected.length, calculated.length + + expected.each do |expected_entry| + calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date } + + assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + end + end + + test "forward portfolio calculation" do + load_prices + + # Build up to 10 shares of VOO (current value $5000) + create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) + create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) + create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) + + # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio + create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) + create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) + + # Build up to 100 shares of WMT (current value $10000) + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + # 4 days ago + Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), + Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 3 days ago + Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), + Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 2 days ago + Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), + + # 1 day ago + Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), + + # Today + Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) + ] + + calculated = Account::HoldingCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + assert_holdings(expected, calculated) + end + + # Carries the previous record forward if no holding exists for a date + # to ensure that net worth historical rollups have a value for every date + test "uses locf to fill missing holdings" do + load_prices + + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000) + ] + + # Price missing today, so we should carry forward the holding from 1 day ago + Security.stubs(:find).returns(@wmt) + Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil) + + calculated = Account::HoldingCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + + assert_holdings(expected, calculated) + end + + private + def assert_holdings(expected, calculated) + expected.each do |expected_entry| + calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date } + + assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + end + end + + def load_prices + @voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460) + Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470) + Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480) + Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490) + Security::Price.create!(security: @voo, date: Date.current, price: 500) + + @wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.") + Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: Date.current, price: 100) + + @amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.") + Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: Date.current, price: 200) + end + + # Portfolio holdings: + # +--------+-----+--------+---------+ + # | Ticker | Qty | Price | Amount | + # +--------+-----+--------+---------+ + # | VOO | 10 | $500 | $5,000 | + # | WMT | 100 | $100 | $10,000 | + # +--------+-----+--------+---------+ + # Brokerage Cash: $5,000 + # Holdings Value: $15,000 + # Total Balance: $20,000 + def load_today_portfolio + @account.update!(cash_balance: 5000) + + load_prices + + @account.holdings.create!( + date: Date.current, + price: 500, + qty: 10, + amount: 5000, + currency: "USD", + security: @voo + ) + + @account.holdings.create!( + date: Date.current, + price: 100, + qty: 100, + amount: 10000, + currency: "USD", + security: @wmt + ) + end +end diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb index 5feb0562..dc521801 100644 --- a/test/models/account/holding_test.rb +++ b/test/models/account/holding_test.rb @@ -5,16 +5,15 @@ class Account::HoldingTest < ActiveSupport::TestCase include Account::EntriesTestHelper, SecuritiesTestHelper setup do - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) + @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, cash_balance: 0, currency: "USD", accountable: Investment.new) # Current day holding instances @amzn, @nvda = load_holdings end test "calculates portfolio weight" do - expected_portfolio_value = 6960.0 - expected_amzn_weight = 3240.0 / expected_portfolio_value * 100 - expected_nvda_weight = 3720.0 / expected_portfolio_value * 100 + expected_amzn_weight = 3240.0 / @account.balance * 100 + expected_nvda_weight = 3720.0 / @account.balance * 100 assert_in_delta expected_amzn_weight, @amzn.weight, 0.001 assert_in_delta expected_nvda_weight, @nvda.weight, 0.001 diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb new file mode 100644 index 00000000..b03ee3d9 --- /dev/null +++ b/test/models/account/syncer_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Account::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "converts foreign account balances to family currency" do + @account.family.update! currency: "USD" + @account.update! currency: "EUR" + + ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2) + ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2) + + Account::BalanceCalculator.any_instance.expects(:calculate).returns( + [ + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR") + ] + ) + + Account::Syncer.new(@account).run + + assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance) + assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance) + end + + test "purges stale balances and holdings" do + # Old, out of range holdings and balances + @account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000) + @account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000) + + assert_equal 1, @account.holdings.count + assert_equal 1, @account.balances.count + + Account::Syncer.new(@account).run + + @account.reload + + assert_equal 0, @account.holdings.count + + # Balance sync always creates 1 balance if no entries present. + assert_equal 1, @account.balances.count + assert_equal 0, @account.balances.first.balance + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index bc8d3965..79945a3e 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -8,7 +8,7 @@ class TradesTest < ApplicationSystemTestCase @account = accounts(:investment) - visit_account_trades + visit_account_portfolio Security.stubs(:search).returns([ Security.new( @@ -66,10 +66,7 @@ class TradesTest < ApplicationSystemTestCase private def open_new_trade_modal - within "[data-testid='activity-menu']" do - click_on "New" - click_on "New transaction" - end + click_on "New transaction" end def within_trades(&block) @@ -77,6 +74,10 @@ class TradesTest < ApplicationSystemTestCase end def visit_account_trades + visit account_path(@account, tab: "activity") + end + + def visit_account_portfolio visit account_path(@account) end diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 0552db92..26db48c3 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -182,7 +182,7 @@ class TransactionsTest < ApplicationSystemTestCase investment_account = accounts(:investment) investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new) transfer_date = Date.current - visit account_url(investment_account) + visit account_url(investment_account, tab: "activity") within "[data-testid='activity-menu']" do click_on "New" click_on "New transaction" From 46131fb496f9a7e0b7652fea5890edc85695c8bb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Dec 2024 18:16:53 -0500 Subject: [PATCH 063/626] Fix unique constraint errors on sync --- app/models/account/syncer.rb | 1 + app/models/investment.rb | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 43ac980e..61a4ef99 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -7,6 +7,7 @@ class Account::Syncer def run holdings = sync_holdings balances = sync_balances(holdings) + account.reload update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_foreign_records(balances) end diff --git a/app/models/investment.rb b/app/models/investment.rb index 29148f06..6f6c1e57 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -25,11 +25,6 @@ class Investment < ApplicationRecord end def post_sync - broadcast_replace_to( - account, - target: "chart_account_#{account.id}", - partial: account.plaid_account_id.present? ? "investments/chart" : "accounts/show/chart", - locals: { account: account } - ) + broadcast_refresh_to account.family end end From b2a56aefc19646cc3bc483fc7d4709fd8764557e Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Dec 2024 18:54:09 -0500 Subject: [PATCH 064/626] Update Plaid cash balance on each sync --- app/models/plaid_account.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 5a6d03e7..8fe342a7 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -37,7 +37,9 @@ class PlaidAccount < ApplicationRecord plaid_subtype: plaid_account_data.subtype, account_attributes: { id: account.id, - balance: plaid_account_data.balances.current + # Plaid guarantees at least 1 of these + balance: plaid_account_data.balances.current || plaid_account_data.balances.available, + cash_balance: derive_plaid_cash_balance(plaid_account_data.balances) } ) end @@ -208,4 +210,13 @@ class PlaidAccount < ApplicationRecord family.merchants.find_or_create_by!(name: plaid_merchant_name) end + + def derive_plaid_cash_balance(plaid_balances) + if account.investment? + plaid_balances.available || 0 + else + # For now, we will not distinguish between "cash" and "overall" balance for non-investment accounts + plaid_balances.current || plaid_balances.available + end + end end From 800eb4c146e6cbc136ccf36cfacff2628ab435d6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 12 Dec 2024 08:56:52 -0500 Subject: [PATCH 065/626] Plaid sync tests and multi-currency investment support (#1531) * Plaid sync tests and multi-currency investment support * Fix system test * Cleanup * Remove data migration --- .../account/holdings_controller.rb | 2 +- app/models/account.rb | 15 +- app/models/account/holding.rb | 5 +- app/models/account/holding_calculator.rb | 10 +- app/models/account/syncer.rb | 66 +++++---- app/models/investment.rb | 4 - app/models/plaid_account.rb | 64 +-------- app/models/plaid_investment_sync.rb | 95 +++++++++++++ app/models/provider/plaid.rb | 27 +--- app/models/provider/synth.rb | 15 +- app/views/account/holdings/_cash.html.erb | 2 +- app/views/account/holdings/index.html.erb | 4 +- app/views/accounts/show/_header.html.erb | 8 +- app/views/shared/_progress_circle.html.erb | 14 +- .../account/holdings_controller_test.rb | 2 +- test/fixtures/securities.yml | 3 + test/models/account/syncer_test.rb | 11 +- test/models/account_test.rb | 9 -- test/models/plaid_investment_sync_test.rb | 82 +++++++++++ test/support/plaid_test_helper.rb | 128 ++++++++++++++++++ test/system/trades_test.rb | 5 +- 21 files changed, 406 insertions(+), 165 deletions(-) create mode 100644 app/models/plaid_investment_sync.rb create mode 100644 test/models/plaid_investment_sync_test.rb create mode 100644 test/support/plaid_test_helper.rb diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index 174d45c6..27ebcd9a 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -23,6 +23,6 @@ class Account::HoldingsController < ApplicationController private def set_holding - @holding = Current.family.holdings.current.find(params[:id]) + @holding = Current.family.holdings.find(params[:id]) end end diff --git a/app/models/account.rb b/app/models/account.rb index feccd7d6..05931b7b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -118,11 +118,8 @@ class Account < ApplicationRecord Money.new(balance_amount, currency) end - def owns_ticker?(ticker) - security_id = Security.find_by(ticker: ticker)&.id - entries.account_trades - .joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id") - .where(account_trades: { security_id: security_id }).any? + def current_holdings + holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end def favorable_direction @@ -151,12 +148,4 @@ class Account < ApplicationRecord entryable: Account::Valuation.new end end - - def holding_qty(security, date: Date.current) - entries.account_trades - .joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id") - .where(account_trades: { security_id: security.id }) - .where("account_entries.date <= ?", date) - .sum("account_trades.qty") - end end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index 432ec2de..d5f3e014 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -9,9 +9,6 @@ class Account::Holding < ApplicationRecord validates :qty, :currency, presence: true scope :chronological, -> { order(:date) } - scope :current, -> { where(date: Date.current).order(amount: :desc) } - scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } - scope :known_value, -> { where.not(amount: nil) } scope :for, ->(security) { where(security_id: security).order(:date) } delegate :ticker, to: :security @@ -29,7 +26,7 @@ class Account::Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price) + avg_cost = account.holdings.for(security).where(currency: currency).where("date <= ?", date).average(:price) Money.new(avg_cost, currency) end diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb index 815e3fdf..b1f6fc2f 100644 --- a/app/models/account/holding_calculator.rb +++ b/app/models/account/holding_calculator.rb @@ -52,13 +52,15 @@ class Account::HoldingCalculator next if price.blank? + converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount + account.holdings.build( security: security.dig(:security), date: date, qty: qty, - price: price.price, - currency: price.currency, - amount: qty * price.price + price: converted_price, + currency: account.currency, + amount: qty * converted_price ) end.compact end @@ -145,7 +147,7 @@ class Account::HoldingCalculator def load_current_holding_quantities holding_quantities = load_empty_holding_quantities - account.holdings.where(date: Date.current).map do |holding| + account.holdings.where(date: Date.current, currency: account.currency).map do |holding| holding_quantities[holding.security_id] = holding.qty end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 61a4ef99..d42ff431 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -9,7 +9,7 @@ class Account::Syncer balances = sync_balances(holdings) account.reload update_account_info(balances, holdings) unless account.plaid_account_id.present? - convert_foreign_records(balances) + convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency end private @@ -37,12 +37,7 @@ class Account::Syncer current_time = Time.now Account.transaction do - account.holdings.upsert_all( - calculated_holdings.map { |h| h.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id security_id date currency] - ) if calculated_holdings.any? + load_holdings(calculated_holdings) # Purge outdated holdings account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id)) @@ -65,24 +60,7 @@ class Account::Syncer calculated_balances end - def convert_foreign_records(balances) - converted_balances = convert_balances(balances) - load_balances(converted_balances) - end - - def load_balances(balances) - current_time = Time.now - account.balances.upsert_all( - balances.map { |b| b.attributes - .slice("date", "balance", "cash_balance", "currency") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id date currency] - ) if balances.any? - end - - def convert_balances(balances) - return [] if account.currency == account.family.currency - + def convert_records_to_family_currency(balances, holdings) from_currency = account.currency to_currency = account.family.currency @@ -92,7 +70,7 @@ class Account::Syncer start_date: balances.first.date ) - balances.map do |balance| + converted_balances = balances.map do |balance| exchange_rate = exchange_rates.find { |er| er.date == balance.date } account.balances.build( @@ -101,5 +79,41 @@ class Account::Syncer currency: to_currency ) if exchange_rate.present? end + + converted_holdings = holdings.map do |holding| + exchange_rate = exchange_rates.find { |er| er.date == holding.date } + + account.holdings.build( + security: holding.security, + date: holding.date, + amount: exchange_rate.rate * holding.amount, + currency: to_currency + ) if exchange_rate.present? + end + + Account.transaction do + load_balances(converted_balances) + load_holdings(converted_holdings) + end + end + + def load_balances(balances = []) + current_time = Time.now + account.balances.upsert_all( + balances.map { |b| b.attributes + .slice("date", "balance", "cash_balance", "currency") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id date currency] + ) + end + + def load_holdings(holdings = []) + current_time = Time.now + account.holdings.upsert_all( + holdings.map { |h| h.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id security_id date currency] + ) end end diff --git a/app/models/investment.rb b/app/models/investment.rb index 6f6c1e57..76e7a57c 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -23,8 +23,4 @@ class Investment < ApplicationRecord def icon "line-chart" end - - def post_sync - broadcast_refresh_to account.family - end end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 8fe342a7..5772f821 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -45,50 +45,7 @@ class PlaidAccount < ApplicationRecord end def sync_investments!(transactions:, holdings:, securities:) - transactions.each do |transaction| - if transaction.type == "cash" - new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.amount - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) - t.entryable = Account::Transaction.new - end - else - security = get_security(transaction.security, securities) - next if security.nil? - new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.quantity * transaction.price - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.entryable = Account::Trade.new( - security: security, - qty: transaction.quantity, - price: transaction.price, - currency: transaction.iso_currency_code - ) - end - end - end - - # Update only the current day holdings. The account sync will populate historical values based on trades. - holdings.each do |holding| - internal_security = get_security(holding.security, securities) - next if internal_security.nil? - - existing_holding = account.holdings.find_or_initialize_by( - security: internal_security, - date: Date.current, - currency: holding.iso_currency_code - ) - - existing_holding.qty = holding.quantity - existing_holding.price = holding.institution_price - existing_holding.amount = holding.quantity * holding.institution_price - existing_holding.save! - end + PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:) end def sync_credit_data!(plaid_credit_data) @@ -159,25 +116,6 @@ class PlaidAccount < ApplicationRecord plaid_item.family end - def get_security(plaid_security, securities) - return nil if plaid_security.nil? - - security = if plaid_security.ticker_symbol.present? - plaid_security - else - securities.find { |s| s.security_id == plaid_security.proxy_security_id } - end - - return nil if security.nil? || security.ticker_symbol.blank? - return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately - - Security.find_or_create_by!( - ticker: security.ticker_symbol, - exchange_mic: security.market_identifier_code || "XNAS", - country_code: "US" - ) - end - def transfer?(plaid_txn) transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ] diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb new file mode 100644 index 00000000..fd207116 --- /dev/null +++ b/app/models/plaid_investment_sync.rb @@ -0,0 +1,95 @@ +class PlaidInvestmentSync + attr_reader :plaid_account + + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def sync!(transactions: [], holdings: [], securities: []) + @transactions = transactions + @holdings = holdings + @securities = securities + + PlaidAccount.transaction do + sync_transactions! + sync_holdings! + end + end + + private + attr_reader :transactions, :holdings, :securities + + def sync_transactions! + transactions.each do |transaction| + security, plaid_security = get_security(transaction.security_id, securities) + + next if security.nil? && plaid_security.nil? + + if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD" + new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.amount + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) + t.entryable = Account::Transaction.new + end + else + new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.quantity * transaction.price + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.entryable = Account::Trade.new( + security: security, + qty: transaction.quantity, + price: transaction.price, + currency: transaction.iso_currency_code + ) + end + end + end + end + + def sync_holdings! + # Update only the current day holdings. The account sync will populate historical values based on trades. + holdings.each do |holding| + internal_security, _plaid_security = get_security(holding.security_id, securities) + + next if internal_security.nil? + + existing_holding = plaid_account.account.holdings.find_or_initialize_by( + security: internal_security, + date: Date.current, + currency: holding.iso_currency_code + ) + + existing_holding.qty = holding.quantity + existing_holding.price = holding.institution_price + existing_holding.amount = holding.quantity * holding.institution_price + existing_holding.save! + end + end + + def get_security(plaid_security_id, securities) + plaid_security = securities.find { |s| s.security_id == plaid_security_id } + + return [ nil, nil ] if plaid_security.nil? + + plaid_security = if plaid_security.ticker_symbol.present? + plaid_security + else + securities.find { |s| s.security_id == plaid_security.proxy_security_id } + end + + return [ nil, nil ] if plaid_security.nil? || plaid_security.ticker_symbol.blank? + + security = Security.find_or_create_by!( + ticker: plaid_security.ticker_symbol, + exchange_mic: plaid_security.market_identifier_code || "XNAS", + country_code: "US" + ) unless plaid_security.ticker_symbol == "CUR:USD" # internally, we do not consider cash a security and track it separately + + [ security, plaid_security ] + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 7fd18291..e41a0a46 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -134,10 +134,12 @@ class Provider::Plaid def get_item_investments(item, start_date: nil, end_date: Date.current) start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date - holdings = get_item_holdings(item) - transactions, securities = get_item_investment_transactions(item, start_date:, end_date:) + holdings, holding_securities = get_item_holdings(item) + transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:) - InvestmentsResponse.new(holdings:, transactions:, securities:) + merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id } + + InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities) end def get_item_liabilities(item) @@ -154,15 +156,7 @@ class Provider::Plaid request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) response = client.investments_holdings_get(request) - securities_by_id = response.securities.index_by(&:security_id) - accounts_by_id = response.accounts.index_by(&:account_id) - - response.holdings.each do |holding| - holding.define_singleton_method(:security) { securities_by_id[holding.security_id] } - holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] } - end - - response.holdings + [ response.holdings, response.securities ] end def get_item_investment_transactions(item, start_date:, end_date:) @@ -179,15 +173,8 @@ class Provider::Plaid ) response = client.investments_transactions_get(request) - securities_by_id = response.securities.index_by(&:security_id) - accounts_by_id = response.accounts.index_by(&:account_id) - - response.investment_transactions.each do |t| - t.define_singleton_method(:security) { securities_by_id[t.security_id] } - t.define_singleton_method(:account) { accounts_by_id[t.account_id] } - transactions << t - end + transactions += response.investment_transactions securities += response.securities break if transactions.length >= response.total_investment_transactions diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 5044f00a..c212e992 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -43,18 +43,23 @@ class Provider::Synth ) end - def fetch_security_prices(ticker:, mic_code:, start_date:, end_date:) - prices = paginate( - "#{base_url}/tickers/#{ticker}/open-close", - mic_code: mic_code, + def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil) + params = { start_date: start_date, end_date: end_date + } + + params[:mic_code] = mic_code if mic_code.present? + + prices = paginate( + "#{base_url}/tickers/#{ticker}/open-close", + params ) do |body| body.dig("prices").map do |price| { date: price.dig("date"), price: price.dig("close")&.to_f || price.dig("open")&.to_f, - currency: "USD" + currency: price.dig("currency") || "USD" } end end diff --git a/app/views/account/holdings/_cash.html.erb b/app/views/account/holdings/_cash.html.erb index cc135a06..51c62f28 100644 --- a/app/views/account/holdings/_cash.html.erb +++ b/app/views/account/holdings/_cash.html.erb @@ -23,7 +23,7 @@
- <%= tag.p format_money account.cash_balance %> + <%= tag.p format_money account.cash_balance_money %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index 8e96fa95..195a2604 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -21,10 +21,10 @@
- <% if @account.holdings.current.any? %> + <% if @account.current_holdings.any? %> <%= render "account/holdings/cash", account: @account %> <%= render "account/holdings/ruler" %> - <%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %> + <%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %> <% else %>

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

<% end %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 5abd0873..a6e925e9 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,13 @@ <% end %>
- <% unless account.plaid_account_id.present? %> + <% if account.plaid_account_id.present? %> + <% if Rails.env.development? %> + <%= button_to sync_plaid_item_path(account.plaid_account.plaid_item), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% end %> + <% end %> + <% else %> <%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> diff --git a/app/views/shared/_progress_circle.html.erb b/app/views/shared/_progress_circle.html.erb index a3529193..f35bda25 100644 --- a/app/views/shared/_progress_circle.html.erb +++ b/app/views/shared/_progress_circle.html.erb @@ -1,7 +1,7 @@ <%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %> -<% - circumference = Math::PI * 2 * radius +<% + circumference = Math::PI * 2 * radius progress_percent = progress.clamp(0, 100) stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 center = radius + stroke / 2 @@ -9,16 +9,15 @@ - + stroke-width="<%= stroke %>"> - + transform="rotate(-90, <%= center %>, <%= center %>)"> diff --git a/test/controllers/account/holdings_controller_test.rb b/test/controllers/account/holdings_controller_test.rb index 7bca9671..b355c061 100644 --- a/test/controllers/account/holdings_controller_test.rb +++ b/test/controllers/account/holdings_controller_test.rb @@ -4,7 +4,7 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) @account = accounts(:investment) - @holding = @account.holdings.current.first + @holding = @account.holdings.first end test "gets holdings" do diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml index 16d48a55..e5e38687 100644 --- a/test/fixtures/securities.yml +++ b/test/fixtures/securities.yml @@ -2,8 +2,11 @@ aapl: ticker: AAPL name: Apple exchange_mic: XNAS + country_code: US msft: ticker: MSFT name: Microsoft exchange_mic: XNAS + country_code: US + diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb index b03ee3d9..bfbf4a87 100644 --- a/test/models/account/syncer_test.rb +++ b/test/models/account/syncer_test.rb @@ -13,7 +13,7 @@ class Account::SyncerTest < ActiveSupport::TestCase ) end - test "converts foreign account balances to family currency" do + test "converts foreign account balances and holdings to family currency" do @account.family.update! currency: "USD" @account.update! currency: "EUR" @@ -27,10 +27,19 @@ class Account::SyncerTest < ActiveSupport::TestCase ] ) + Account::HoldingCalculator.any_instance.expects(:calculate).returns( + [ + Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, amount: 500, currency: "EUR"), + Account::Holding.new(security: securities(:aapl), date: Date.current, amount: 500, currency: "EUR") + ] + ) + Account::Syncer.new(@account).run assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance) assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance) + assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount) + assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount) end test "purges stale balances and holdings" do diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 15dc923f..f122c17e 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -59,13 +59,4 @@ class AccountTest < ActiveSupport::TestCase assert_equal 0, @account.series(currency: "NZD").values.count end end - - test "calculates shares owned of holding for date" do - account = accounts(:investment) - security = securities(:aapl) - - assert_equal 10, account.holding_qty(security, date: Date.current) - assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date) - assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date) - end end diff --git a/test/models/plaid_investment_sync_test.rb b/test/models/plaid_investment_sync_test.rb new file mode 100644 index 00000000..052a941a --- /dev/null +++ b/test/models/plaid_investment_sync_test.rb @@ -0,0 +1,82 @@ +require "test_helper" + +class PlaidInvestmentSyncTest < ActiveSupport::TestCase + include PlaidTestHelper + + setup do + @plaid_account = plaid_accounts(:one) + end + + test "syncs basic investments and handles cash holding" do + assert_equal 0, @plaid_account.account.entries.count + assert_equal 0, @plaid_account.account.holdings.count + + plaid_aapl_id = "aapl_id" + + transactions = [ + create_plaid_investment_transaction({ + investment_transaction_id: "inv_txn_1", + security_id: plaid_aapl_id, + quantity: 10, + price: 200, + date: 5.days.ago.to_date, + type: "buy" + }) + ] + + holdings = [ + create_plaid_cash_holding, + create_plaid_holding({ + security_id: plaid_aapl_id, + quantity: 10, + institution_price: 200, + cost_basis: 2000 + }) + ] + + securities = [ + create_plaid_security({ + security_id: plaid_aapl_id, + close_price: 200, + ticker_symbol: "AAPL" + }) + ] + + # Cash holding should be ignored, resulting in 1, NOT 2 total holdings after sync + assert_difference -> { Account::Trade.count } => 1, + -> { Account::Transaction.count } => 0, + -> { Account::Holding.count } => 1, + -> { Security.count } => 0 do + PlaidInvestmentSync.new(@plaid_account).sync!( + transactions: transactions, + holdings: holdings, + securities: securities + ) + end + end + + # Some cash transactions from Plaid are labeled as type: "cash" while others are linked to a "cash" security + # In both cases, we should treat them as cash-only transactions (not trades) + test "handles cash investment transactions" do + transactions = [ + create_plaid_investment_transaction({ + price: 1, + quantity: 5, + amount: 5, + type: "fee", + subtype: "miscellaneous fee", + security_id: PLAID_TEST_CASH_SECURITY_ID + }) + ] + + assert_difference -> { Account::Trade.count } => 0, + -> { Account::Transaction.count } => 1, + -> { Security.count } => 0 do + PlaidInvestmentSync.new(@plaid_account).sync!( + transactions: transactions, + holdings: [ create_plaid_cash_holding ], + securities: [ create_plaid_cash_security ] + ) + end + end +end diff --git a/test/support/plaid_test_helper.rb b/test/support/plaid_test_helper.rb new file mode 100644 index 00000000..b732bb97 --- /dev/null +++ b/test/support/plaid_test_helper.rb @@ -0,0 +1,128 @@ +require "ostruct" + +module PlaidTestHelper + PLAID_TEST_ACCOUNT_ID = "plaid_test_account_id" + PLAID_TEST_CASH_SECURITY_ID = "plaid_test_cash_security_id" + + # Special case + def create_plaid_cash_security(attributes = {}) + default_attributes = { + close_price: nil, + close_price_as_of: nil, + cusip: nil, + fixed_income: nil, + industry: nil, + institution_id: nil, + institution_security_id: nil, + is_cash_equivalent: false, # Plaid sometimes returns false here (bad data), so we should not rely on it + isin: nil, + iso_currency_code: "USD", + market_identifier_code: nil, + name: "US Dollar", + option_contract: nil, + proxy_security_id: nil, + sector: nil, + security_id: PLAID_TEST_CASH_SECURITY_ID, + sedol: nil, + ticker_symbol: "CUR:USD", + type: "cash", + unofficial_currency_code: nil, + update_datetime: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_security(attributes = {}) + default_attributes = { + close_price: 606.71, + close_price_as_of: Date.current, + cusip: nil, + fixed_income: nil, + industry: "Mutual Funds", + institution_id: nil, + institution_security_id: nil, + is_cash_equivalent: false, + isin: nil, + iso_currency_code: "USD", + market_identifier_code: "XNAS", + name: "iShares S&P 500 Index", + option_contract: nil, + proxy_security_id: nil, + sector: "Financial", + security_id: "plaid_test_security_id", + sedol: "2593025", + ticker_symbol: "IVV", + type: "etf", + unofficial_currency_code: nil, + update_datetime: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_cash_holding(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + cost_basis: 1000, + institution_price: 1, + institution_price_as_of: Date.current, + iso_currency_code: "USD", + quantity: 1000, + security_id: PLAID_TEST_CASH_SECURITY_ID, + unofficial_currency_code: nil, + vested_quantity: nil, + vested_value: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_holding(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + cost_basis: 2000, + institution_price: 200, + institution_price_as_of: Date.current, + iso_currency_code: "USD", + quantity: 10, + security_id: "plaid_test_security_id", + unofficial_currency_code: nil, + vested_quantity: nil, + vested_value: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end + + def create_plaid_investment_transaction(attributes = {}) + default_attributes = { + account_id: PLAID_TEST_ACCOUNT_ID, + amount: 500, + cancel_transaction_id: nil, + date: 5.days.ago.to_date, + fees: 0, + investment_transaction_id: "plaid_test_investment_transaction_id", + iso_currency_code: "USD", + name: "Buy 100 shares of IVV", + price: 606.71, + quantity: 100, + security_id: "plaid_test_security_id", + type: "buy", + subtype: "buy", + unofficial_currency_code: nil + } + + OpenStruct.new( + default_attributes.merge(attributes) + ) + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 79945a3e..9e26b708 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -16,7 +16,8 @@ class TradesTest < ApplicationSystemTestCase name: "Apple Inc.", logo_url: "https://logo.synthfinance.com/ticker/AAPL", exchange_acronym: "NASDAQ", - exchange_mic: "XNAS" + exchange_mic: "XNAS", + country_code: "US" ) ]) end @@ -43,7 +44,7 @@ class TradesTest < ApplicationSystemTestCase end test "can create sell transaction" do - aapl = @account.holdings.current.find { |h| h.security.ticker == "AAPL" } + aapl = @account.holdings.find { |h| h.security.ticker == "AAPL" } open_new_trade_modal From 027c18297b355600e32482f00a3e2fe28b212c93 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 12 Dec 2024 15:11:06 -0500 Subject: [PATCH 066/626] Fix holding avg cost calculation --- app/models/account/holding.rb | 8 ++++++-- app/views/shared/_money_field.html.erb | 1 - test/models/account/holding_test.rb | 9 +++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index d5f3e014..bd2e1ca3 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -26,8 +26,12 @@ class Account::Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - avg_cost = account.holdings.for(security).where(currency: currency).where("date <= ?", date).average(:price) - Money.new(avg_cost, currency) + avg_cost = account.entries.account_trades + .joins("INNER JOIN account_trades ON account_trades.id = account_entries.entryable_id") + .where("account_trades.security_id = ? AND account_trades.qty > 0 AND account_entries.date <= ?", security.id, date) + .average(:price) + + Money.new(avg_cost || price, currency) end def trend diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 4a3f8001..a2db645c 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -36,7 +36,6 @@ step: currency.step, data: { "money-field-target": "amount", - action: "change->money-field#handleAmountChange", "auto-submit-form-target": ("auto" if options[:auto_submit]) }.compact, required: options[:required] %> diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb index dc521801..c3bf1c39 100644 --- a/test/models/account/holding_test.rb +++ b/test/models/account/holding_test.rb @@ -20,11 +20,20 @@ class Account::HoldingTest < ActiveSupport::TestCase end test "calculates simple average cost basis" do + create_trade(@amzn.security, account: @account, qty: 10, price: 212.00, date: 1.day.ago.to_date) + create_trade(@amzn.security, account: @account, qty: 15, price: 216.00, date: Date.current) + + create_trade(@nvda.security, account: @account, qty: 5, price: 128.00, date: 1.day.ago.to_date) + create_trade(@nvda.security, account: @account, qty: 30, price: 124.00, date: Date.current) + assert_equal Money.new((212.0 + 216.0) / 2), @amzn.avg_cost assert_equal Money.new((128.0 + 124.0) / 2), @nvda.avg_cost end test "calculates total return trend" do + @amzn.stubs(:avg_cost).returns(Money.new(214.00)) + @nvda.stubs(:avg_cost).returns(Money.new(126.00)) + # Gained $30, or 0.93% assert_equal Money.new(30), @amzn.trend.value assert_in_delta 0.9, @amzn.trend.percent, 0.001 From 4866a4f8e4913118372d88d54a97fa25dcf88cbd Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 12 Dec 2024 15:14:54 -0500 Subject: [PATCH 067/626] Increase cache time for upgrades Fixes #1525 --- app/models/provider/github.rb | 2 +- config/initializers/good_job.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/provider/github.rb b/app/models/provider/github.rb index 5aa8526a..51e2556c 100644 --- a/app/models/provider/github.rb +++ b/app/models/provider/github.rb @@ -8,7 +8,7 @@ class Provider::Github end def fetch_latest_upgrade_candidates - Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 2.minutes) do + Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 30.minutes) do Rails.logger.info "Fetching latest GitHub upgrade candidates from #{repo} on branch #{branch}..." begin latest_release = Octokit.releases(repo).first diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 5d8aae25..9ca92c0f 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -4,7 +4,7 @@ Rails.application.configure do if ENV["UPGRADES_ENABLED"] == "true" config.good_job.cron = { auto_upgrade: { - cron: "every 30 seconds", + cron: "every 2 minutes", class: "AutoUpgradeJob", description: "Check for new versions of the app and upgrade if necessary" } From bac2e64c1965006137c3ccdadc0e818f47e7e01a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 13 Dec 2024 12:16:21 -0500 Subject: [PATCH 068/626] Bump to v0.2.0 Signed-off-by: Zach Gollwitzer --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 4aab87f6..a81c355e 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -10,7 +10,7 @@ module Maybe private def semver - "0.2.0-alpha.2" + "0.2.0" end end end From fe199f235784a96d9b25efe846d87e6f5fb010e7 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 13 Dec 2024 17:22:27 -0500 Subject: [PATCH 069/626] Add account data enrichment (#1532) * Add data enrichment * Make data enrichment optional for self-hosters * Add categories to data enrichment * Only update category and merchant if nil * Fix name overrides * Lint fixes --- .../settings/hostings_controller.rb | 6 +- app/jobs/enrich_data_job.rb | 7 +++ app/models/account.rb | 8 +++ app/models/account/data_enricher.rb | 61 +++++++++++++++++++ app/models/account/syncer.rb | 6 ++ app/models/concerns/providable.rb | 6 +- app/models/provider/synth.rb | 31 ++++++++++ app/models/setting.rb | 4 ++ .../transactions/_transaction.html.erb | 12 ++-- app/views/merchants/_merchant.html.erb | 9 ++- .../_data_enrichment_settings.html.erb | 18 ++++++ app/views/settings/hostings/show.html.erb | 1 + config/locales/views/settings/hostings/en.yml | 3 + .../20241212141453_add_merchant_logo.rb | 8 +++ db/schema.rb | 5 +- test/jobs/enrich_data_job_test.rb | 7 +++ 16 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 app/jobs/enrich_data_job.rb create mode 100644 app/models/account/data_enricher.rb create mode 100644 app/views/settings/hostings/_data_enrichment_settings.html.erb create mode 100644 db/migrate/20241212141453_add_merchant_logo.rb create mode 100644 test/jobs/enrich_data_job_test.rb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 222ae018..97b8de92 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -26,6 +26,10 @@ class Settings::HostingsController < SettingsController Setting.synth_api_key = hosting_params[:synth_api_key] end + if hosting_params.key?(:data_enrichment_enabled) + Setting.data_enrichment_enabled = hosting_params[:data_enrichment_enabled] + end + redirect_to settings_hosting_path, notice: t(".success") rescue ActiveRecord::RecordInvalid => error flash.now[:alert] = t(".failure") @@ -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, :synth_api_key, :data_enrichment_enabled) end def raise_if_not_self_hosted diff --git a/app/jobs/enrich_data_job.rb b/app/jobs/enrich_data_job.rb new file mode 100644 index 00000000..97286b82 --- /dev/null +++ b/app/jobs/enrich_data_job.rb @@ -0,0 +1,7 @@ +class EnrichDataJob < ApplicationJob + queue_as :default + + def perform(account) + account.enrich_data + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 05931b7b..400e8bea 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -126,6 +126,14 @@ class Account < ApplicationRecord classification == "asset" ? "up" : "down" end + def enrich_data + DataEnricher.new(self).run + end + + def enrich_data_later + EnrichDataJob.perform_later(self) + end + def update_with_sync!(attributes) transaction do update!(attributes) diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb new file mode 100644 index 00000000..4beb0b92 --- /dev/null +++ b/app/models/account/data_enricher.rb @@ -0,0 +1,61 @@ +class Account::DataEnricher + include Providable + + attr_reader :account + + def initialize(account) + @account = account + end + + def run + enrich_transactions + end + + private + def enrich_transactions + candidates = account.entries.account_transactions.includes(entryable: [ :merchant, :category ]) + + Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}") + + merchants = {} + categories = {} + + candidates.each do |entry| + if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil? + begin + info = self.class.synth_provider.enrich_transaction(entry.name).info + + next unless info.present? + + if info.name.present? + merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name) + + if info.icon_url.present? + merchant.icon_url = info.icon_url + end + end + + if info.category.present? + category = categories[info.category] ||= account.family.categories.find_or_create_by(name: info.category) + end + + entryable_attributes = { id: entry.entryable_id } + entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil? + entryable_attributes[:category_id] = category.id if category.present? && entry.entryable.category_id.nil? + + Account.transaction do + merchant.save! if merchant.present? + category.save! if category.present? + entry.update!( + enriched_at: Time.current, + name: entry.enriched_at.nil? ? info.name : entry.name, + entryable_attributes: entryable_attributes + ) + end + rescue => e + Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}") + end + end + end + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index d42ff431..9160e64f 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -10,6 +10,12 @@ class Account::Syncer account.reload update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency + + if Setting.data_enrichment_enabled || Rails.configuration.app_mode.managed? + account.enrich_data_later + else + Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") + end end private diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index 996efff8..4a8de8c0 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -23,8 +23,10 @@ module Providable end def synth_provider - api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] - api_key.present? ? Provider::Synth.new(api_key) : nil + @synth_provider ||= begin + api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] + api_key.present? ? Provider::Synth.new(api_key) : nil + end end private diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index c212e992..b7735575 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -167,6 +167,35 @@ class Provider::Synth raw_response: response end + def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) + params = { + description: description, + amount: amount, + date: date, + city: city, + state: state, + country: country + }.compact + + response = client.get("#{base_url}/enrich", params) + + parsed = JSON.parse(response.body) + + EnrichTransactionResponse.new \ + info: EnrichTransactionInfo.new( + name: parsed.dig("merchant"), + icon_url: parsed.dig("icon"), + category: parsed.dig("category") + ), + success?: true, + raw_response: response + rescue StandardError => error + EnrichTransactionResponse.new \ + success?: false, + error: error, + raw_response: error + end + private attr_reader :api_key @@ -177,6 +206,8 @@ class Provider::Synth UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true + EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true + EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" diff --git a/app/models/setting.rb b/app/models/setting.rb index d576fbea..eb1a9369 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -17,6 +17,10 @@ class Setting < RailsSettings::Base default: ENV.fetch("UPGRADES_TARGET", "release"), validates: { inclusion: { in: %w[release commit] } } + field :data_enrichment_enabled, + type: :boolean, + default: true + field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] field :require_invite_for_signup, type: :boolean, default: false diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index dc2a026a..9a37a958 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -11,15 +11,17 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> -
- <%= transaction.name.first.upcase %> -
+ <% if entry.account_transaction.merchant&.icon_url %> + <%= image_tag entry.account_transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> + <% else %> + <%= render "shared/circle_logo", name: entry.name, size: "sm" %> + <% end %>
<% if entry.new_record? %> - <%= content_tag :p, transaction.name %> + <%= content_tag :p, entry.name %> <% else %> - <%= link_to transaction.name, + <%= link_to entry.name, entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/merchants/_merchant.html.erb b/app/views/merchants/_merchant.html.erb index 2b964140..a454d41a 100644 --- a/app/views/merchants/_merchant.html.erb +++ b/app/views/merchants/_merchant.html.erb @@ -2,7 +2,14 @@
- <%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %> + <% if merchant.icon_url %> +
+ <%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %> +
+ <% else %> + <%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %> + <% end %> +

<%= merchant.name %>

diff --git a/app/views/settings/hostings/_data_enrichment_settings.html.erb b/app/views/settings/hostings/_data_enrichment_settings.html.erb new file mode 100644 index 00000000..6d409923 --- /dev/null +++ b/app/views/settings/hostings/_data_enrichment_settings.html.erb @@ -0,0 +1,18 @@ +
+
+
+

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

+

<%= t(".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 :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> + <%= form.label :data_enrichment_enabled, " ".html_safe, class: "maybe-switch" %> +
+ <% end %> +
+
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index ba4b7d5d..a2af0bed 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -10,6 +10,7 @@ <%= render "settings/hostings/upgrade_settings" %> <%= render "settings/hostings/provider_settings" %> <%= render "settings/hostings/synth_settings" %> + <%= render "settings/hostings/data_enrichment_settings" %>
<% end %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 90a89fd7..6b34a6cd 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -2,6 +2,9 @@ en: settings: hostings: + data_enrichment_settings: + description: Enable data enrichment for your accounts such as merchant info, transaction description cleanup, and more + title: Data Enrichment invite_code_settings: description: Every new user that joins your instance of Maybe can only do so via an invite code diff --git a/db/migrate/20241212141453_add_merchant_logo.rb b/db/migrate/20241212141453_add_merchant_logo.rb new file mode 100644 index 00000000..81bd198c --- /dev/null +++ b/db/migrate/20241212141453_add_merchant_logo.rb @@ -0,0 +1,8 @@ +class AddMerchantLogo < ActiveRecord::Migration[7.2] + def change + add_column :merchants, :icon_url, :string + add_column :merchants, :enriched_at, :datetime + + add_column :account_entries, :enriched_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 31183733..5fd3f26d 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: 2024_12_07_002408) do +ActiveRecord::Schema[7.2].define(version: 2024_12_12_141453) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -48,6 +48,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_07_002408) do t.text "notes" t.boolean "excluded", default: false t.string "plaid_id" + t.datetime "enriched_at" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" @@ -452,6 +453,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_07_002408) do t.uuid "family_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "icon_url" + t.datetime "enriched_at" t.index ["family_id"], name: "index_merchants_on_family_id" end diff --git a/test/jobs/enrich_data_job_test.rb b/test/jobs/enrich_data_job_test.rb new file mode 100644 index 00000000..067767f6 --- /dev/null +++ b/test/jobs/enrich_data_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EnrichDataJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 913008995016c95e279c2c4842c5457175991388 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 16 Dec 2024 10:37:59 -0500 Subject: [PATCH 070/626] Make data enrichment opt-in --- app/models/setting.rb | 2 +- config/locales/views/settings/hostings/en.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/setting.rb b/app/models/setting.rb index eb1a9369..fe047cbb 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -19,7 +19,7 @@ class Setting < RailsSettings::Base field :data_enrichment_enabled, type: :boolean, - default: true + default: false field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 6b34a6cd..dcd65f06 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -3,7 +3,7 @@ en: settings: hostings: data_enrichment_settings: - description: Enable data enrichment for your accounts such as merchant info, transaction description cleanup, and more + description: Enable data enrichment for your account transactions. This will incur additional Synth credits. title: Data Enrichment invite_code_settings: description: Every new user that joins your instance of Maybe can only do From 45add7512b000120a0e740f987ed0397b15296a1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 16 Dec 2024 12:52:11 -0500 Subject: [PATCH 071/626] Handle nil name for entries (#1550) * Handle nil name for entries * Fix tests --- .erb-lint.yml => .erb_lint.yml | 0 app/models/account/data_enricher.rb | 4 ++- app/models/account/transaction.rb | 2 +- app/models/account/valuation.rb | 4 +++ .../transactions/_transaction.html.erb | 6 ++--- .../account/valuations/_valuation.html.erb | 4 +-- app/views/shared/_circle_logo.html.erb | 2 +- bin/erblint | 27 ------------------- .../locales/views/account/valuations/en.yml | 2 -- config/locales/views/settings/hostings/en.yml | 3 ++- 10 files changed, 16 insertions(+), 38 deletions(-) rename .erb-lint.yml => .erb_lint.yml (100%) delete mode 100755 bin/erblint diff --git a/.erb-lint.yml b/.erb_lint.yml similarity index 100% rename from .erb-lint.yml rename to .erb_lint.yml diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb index 4beb0b92..e0615cc1 100644 --- a/app/models/account/data_enricher.rb +++ b/app/models/account/data_enricher.rb @@ -23,6 +23,8 @@ class Account::DataEnricher candidates.each do |entry| if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil? begin + next unless entry.name.present? + info = self.class.synth_provider.enrich_transaction(entry.name).info next unless info.present? @@ -48,7 +50,7 @@ class Account::DataEnricher category.save! if category.present? entry.update!( enriched_at: Time.current, - name: entry.enriched_at.nil? ? info.name : entry.name, + name: entry.enriched_at.nil? && info.name ? info.name : entry.name, entryable_attributes: entryable_attributes ) end diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index 91f24f70..fbf2aa9e 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -49,7 +49,7 @@ class Account::Transaction < ApplicationRecord end def name - entry.name || "(no description)" + entry.name || (entry.amount.positive? ? "Expense" : "Income") end def eod_balance diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index 93ebf5ff..5a4d1b8f 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -10,4 +10,8 @@ class Account::Valuation < ApplicationRecord false end end + + def name + "Balance update" + end end diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 9a37a958..d63ddf8b 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -14,14 +14,14 @@ <% if entry.account_transaction.merchant&.icon_url %> <%= image_tag entry.account_transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> <% else %> - <%= render "shared/circle_logo", name: entry.name, size: "sm" %> + <%= render "shared/circle_logo", name: transaction.name, size: "sm" %> <% end %>
<% if entry.new_record? %> - <%= content_tag :p, entry.name %> + <%= content_tag :p, transaction.name %> <% else %> - <%= link_to entry.name, + <%= link_to transaction.name, entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index fc4c05d0..0761fd17 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -18,9 +18,9 @@
<% if entry.new_record? %> - <%= content_tag :p, entry.name %> + <%= content_tag :p, entry.entryable.name %> <% else %> - <%= link_to entry.name || t(".balance_update"), + <%= link_to entry.entryable.name, account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/shared/_circle_logo.html.erb b/app/views/shared/_circle_logo.html.erb index 17f1948a..e8dd7c1f 100644 --- a/app/views/shared/_circle_logo.html.erb +++ b/app/views/shared/_circle_logo.html.erb @@ -9,5 +9,5 @@ <%= tag.div style: mixed_hex_styles(hex), class: [size_classes[size], "flex shrink-0 items-center justify-center rounded-full"] do %> - <%= tag.span name[0].upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %> + <%= tag.span (name.presence&.first || "T").upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %> <% end %> diff --git a/bin/erblint b/bin/erblint deleted file mode 100755 index 63d662cf..00000000 --- a/bin/erblint +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'erblint' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -bundle_binstub = File.expand_path("bundle", __dir__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("erb_lint", "erblint") diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index ba8637c6..c157b6d6 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -2,8 +2,6 @@ en: account: valuations: - valuation: - balance_update: Balance update form: amount: Amount submit: Add balance update diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index dcd65f06..b3f04fc9 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -3,7 +3,8 @@ en: settings: hostings: data_enrichment_settings: - description: Enable data enrichment for your account transactions. This will incur additional Synth credits. + description: Enable data enrichment for your account transactions. This will + incur additional Synth credits. title: Data Enrichment invite_code_settings: description: Every new user that joins your instance of Maybe can only do From f7e86d4c90a51923b0bf918b3ef0356537fade13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:52:29 -0500 Subject: [PATCH 072/626] Bump rails from 7.2.2 to 7.2.2.1 (#1546) Bumps [rails](https://github.com/rails/rails) from 7.2.2 to 7.2.2.1. - [Release notes](https://github.com/rails/rails/releases) - [Commits](https://github.com/rails/rails/compare/v7.2.2...v7.2.2.1) --- updated-dependencies: - dependency-name: 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 | 134 +++++++++++++++++++++++++-------------------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7e2318e0..88371eff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,29 +8,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + actioncable (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actionmailbox (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) mail (>= 2.8.0) - actionmailer (7.2.2) - actionpack (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activesupport (= 7.2.2) + actionmailer (7.2.2.1) + actionpack (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activesupport (= 7.2.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2) - actionview (= 7.2.2) - activesupport (= 7.2.2) + actionpack (7.2.2.1) + actionview (= 7.2.2.1) + activesupport (= 7.2.2.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -39,35 +39,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.2) - actionpack (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actiontext (7.2.2.1) + actionpack (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2) - activesupport (= 7.2.2) + actionview (7.2.2.1) + activesupport (= 7.2.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.2) - activesupport (= 7.2.2) + activejob (7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.3.6) - activemodel (7.2.2) - activesupport (= 7.2.2) - activerecord (7.2.2) - activemodel (= 7.2.2) - activesupport (= 7.2.2) + activemodel (7.2.2.1) + activesupport (= 7.2.2.1) + activerecord (7.2.2.1) + activemodel (= 7.2.2.1) + activesupport (= 7.2.2.1) timeout (>= 0.4.0) - activestorage (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activesupport (= 7.2.2) + activestorage (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activesupport (= 7.2.2.1) marcel (~> 1.0) - activesupport (7.2.2) + activesupport (7.2.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -133,7 +133,7 @@ GEM rexml crass (1.0.6) csv (3.3.0) - date (3.4.0) + date (3.4.1) debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) @@ -218,7 +218,7 @@ GEM intercom-rails (1.0.1) activesupport (> 4.0) io-console (0.8.0) - irb (1.14.1) + irb (1.14.2) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) @@ -234,7 +234,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.2) + logger (1.6.3) loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -254,7 +254,7 @@ GEM multipart-post (2.4.1) net-http (0.5.0) uri - net-imap (0.5.0) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) @@ -264,17 +264,17 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.17.0-aarch64-linux) + nokogiri (1.17.2-aarch64-linux) racc (~> 1.4) - nokogiri (1.17.0-arm-linux) + nokogiri (1.17.2-arm-linux) racc (~> 1.4) - nokogiri (1.17.0-arm64-darwin) + nokogiri (1.17.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.0-x86-linux) + nokogiri (1.17.2-x86-linux) racc (~> 1.4) - nokogiri (1.17.0-x86_64-darwin) + nokogiri (1.17.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.0-x86_64-linux) + nokogiri (1.17.2-x86_64-linux) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) @@ -309,25 +309,25 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (7.2.2) - actioncable (= 7.2.2) - actionmailbox (= 7.2.2) - actionmailer (= 7.2.2) - actionpack (= 7.2.2) - actiontext (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activemodel (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + rails (7.2.2.1) + actioncable (= 7.2.2.1) + actionmailbox (= 7.2.2.1) + actionmailer (= 7.2.2.1) + actionpack (= 7.2.2.1) + actiontext (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activemodel (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) bundler (>= 1.15.0) - railties (= 7.2.2) + railties (= 7.2.2.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) + 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) @@ -336,9 +336,9 @@ GEM rails-settings-cached (2.9.5) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + railties (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -351,7 +351,7 @@ GEM ffi (~> 1.0) rbs (3.6.1) logger - rdoc (6.8.1) + rdoc (6.9.0) psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.9.2) @@ -402,7 +402,7 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - securerandom (0.4.0) + securerandom (0.4.1) selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) @@ -440,7 +440,7 @@ GEM terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) thor (1.3.2) - timeout (0.4.2) + timeout (0.4.3) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) From f7ce2cdf8912bd28e3c8ff390a495df244ed9f90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:00:08 -0500 Subject: [PATCH 073/626] Bump mocha from 2.7.0 to 2.7.1 (#1544) Bumps [mocha](https://github.com/freerange/mocha) from 2.7.0 to 2.7.1. - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v2.7.0...v2.7.1) --- updated-dependencies: - dependency-name: mocha 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 88371eff..90564356 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -248,7 +248,7 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.4) - mocha (2.7.0) + mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) From 0d09f2e3e91204a119bcc2573c5d123e7e0b2c6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:00:25 -0500 Subject: [PATCH 074/626] Bump csv from 3.3.0 to 3.3.1 (#1543) Bumps [csv](https://github.com/ruby/csv) from 3.3.0 to 3.3.1. - [Release notes](https://github.com/ruby/csv/releases) - [Changelog](https://github.com/ruby/csv/blob/master/NEWS.md) - [Commits](https://github.com/ruby/csv/compare/v3.3.0...v3.3.1) --- updated-dependencies: - dependency-name: csv 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 90564356..bfddd858 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,7 +132,7 @@ GEM bigdecimal rexml crass (1.0.6) - csv (3.3.0) + csv (3.3.1) date (3.4.1) debug (1.9.2) irb (~> 1.10) From 54e46c1b4ed08470336b1e56827d4fd5606f51a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:01:05 -0500 Subject: [PATCH 075/626] Bump faraday from 2.12.1 to 2.12.2 (#1542) Bumps [faraday](https://github.com/lostisland/faraday) from 2.12.1 to 2.12.2. - [Release notes](https://github.com/lostisland/faraday/releases) - [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md) - [Commits](https://github.com/lostisland/faraday/compare/v2.12.1...v2.12.2) --- updated-dependencies: - dependency-name: faraday 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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bfddd858..6560b704 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,7 +155,7 @@ GEM tzinfo faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.1) + faraday (2.12.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -222,7 +222,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.8.2) + json (2.9.0) jwt (2.9.3) base64 language_server-protocol (3.17.0.3) @@ -252,7 +252,7 @@ GEM ruby2_keywords (>= 0.0.5) msgpack (1.7.2) multipart-post (2.4.1) - net-http (0.5.0) + net-http (0.6.0) uri net-imap (0.5.1) date From bb9fa56addc52eea96684c318018d8b895347f9c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 16 Dec 2024 13:21:30 -0500 Subject: [PATCH 076/626] Fix date format validation error (#1551) * Fix date format validation error * Order trades, fix flaky test --- app/helpers/application_helper.rb | 2 +- app/models/account/holding_calculator.rb | 2 +- app/models/family.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8bf3cf28..24c962df 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -4,7 +4,7 @@ module ApplicationHelper def date_format_options [ [ "DD-MM-YYYY", "%d-%m-%Y" ], - [ "DD.MM.YY", "%d.%m.%Y" ], + [ "DD.MM.YYYY", "%d.%m.%Y" ], [ "MM-DD-YYYY", "%m-%d-%Y" ], [ "YYYY-MM-DD", "%Y-%m-%d" ], [ "DD/MM/YYYY", "%d/%m/%Y" ], diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb index b1f6fc2f..5f6673de 100644 --- a/app/models/account/holding_calculator.rb +++ b/app/models/account/holding_calculator.rb @@ -98,7 +98,7 @@ class Account::HoldingCalculator end def trades - @trades ||= account.entries.includes(entryable: :security).account_trades.to_a + @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a end def portfolio_start_date diff --git a/app/models/family.rb b/app/models/family.rb index bae32843..69ac5eb7 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,7 @@ class Family < ApplicationRecord include Plaidable, Syncable - DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] + DATE_FORMATS = [ "%m-%d-%Y", "%d.%m.%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] include Providable From 7508ae55ac65afec524b8b2b3e16041f4dab8847 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:42:11 -0500 Subject: [PATCH 077/626] Bump dotenv-rails from 3.1.4 to 3.1.6 (#1540) Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.4 to 3.1.6. - [Release notes](https://github.com/bkeepers/dotenv/releases) - [Changelog](https://github.com/bkeepers/dotenv/blob/main/Changelog.md) - [Commits](https://github.com/bkeepers/dotenv/compare/v3.1.4...v3.1.6) --- updated-dependencies: - dependency-name: dotenv-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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6560b704..a8ea5877 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,9 +138,9 @@ GEM irb (~> 1.10) reline (>= 0.3.8) docile (1.4.0) - dotenv (3.1.4) - dotenv-rails (3.1.4) - dotenv (= 3.1.4) + dotenv (3.1.6) + dotenv-rails (3.1.6) + dotenv (= 3.1.6) railties (>= 6.1) drb (2.2.1) erb_lint (0.7.0) @@ -351,11 +351,11 @@ GEM ffi (~> 1.0) rbs (3.6.1) logger - rdoc (6.9.0) + rdoc (6.9.1) psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.9.2) - reline (0.5.12) + reline (0.6.0) io-console (~> 0.5) rexml (3.3.9) rubocop (1.67.0) From ae30176816de5080c68e1d2a58d399d2ad0c1983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:42:27 -0500 Subject: [PATCH 078/626] Bump aws-sdk-s3 from 1.176.0 to 1.176.1 (#1545) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.176.0 to 1.176.1. - [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-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a8ea5877..2843a0c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,7 +83,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1018.0) + aws-partitions (1.1023.0) aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -92,7 +92,7 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.176.0) + aws-sdk-s3 (1.176.1) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 6034dfe5f5347a0d8f1d6b9b083bf223ba86520f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:42:36 -0500 Subject: [PATCH 079/626] Bump good_job from 4.5.1 to 4.6.0 (#1541) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.5.1 to 4.6.0. - [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.5.1...v4.6.0) --- 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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2843a0c1..0cfab904 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.5.1) + good_job (4.6.0) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From ba878c3d8b392dce46c1bf8dab14bf7ff4733f7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:00:08 -0500 Subject: [PATCH 080/626] Bump rails-settings-cached from 2.9.5 to 2.9.6 (#1547) Bumps [rails-settings-cached](https://github.com/huacnlee/rails-settings-cached) from 2.9.5 to 2.9.6. - [Release notes](https://github.com/huacnlee/rails-settings-cached/releases) - [Changelog](https://github.com/huacnlee/rails-settings-cached/blob/main/CHANGELOG.md) - [Commits](https://github.com/huacnlee/rails-settings-cached/compare/v2.9.5...v2.9.6) --- updated-dependencies: - dependency-name: rails-settings-cached 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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0cfab904..1a80b26c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -333,7 +333,7 @@ GEM rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - rails-settings-cached (2.9.5) + rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) railties (7.2.2.1) From 68617514b004e71b8750f9506de2dc2a9629992a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 17 Dec 2024 09:58:08 -0500 Subject: [PATCH 081/626] Make transaction enrichment opt-in for all users (#1552) --- .../settings/hostings_controller.rb | 6 +---- app/controllers/users_controller.rb | 2 +- app/models/account/syncer.rb | 2 +- app/models/setting.rb | 4 ---- .../_data_enrichment_settings.html.erb | 18 -------------- app/views/settings/hostings/show.html.erb | 1 - .../_data_enrichment_settings.html.erb | 24 +++++++++++++++++++ app/views/settings/preferences/show.html.erb | 5 ++++ config/locales/views/settings/en.yml | 7 ++++++ config/locales/views/settings/hostings/en.yml | 4 ---- .../20241217141716_add_enrichment_setting.rb | 5 ++++ db/schema.rb | 3 ++- 12 files changed, 46 insertions(+), 35 deletions(-) delete mode 100644 app/views/settings/hostings/_data_enrichment_settings.html.erb create mode 100644 app/views/settings/preferences/_data_enrichment_settings.html.erb create mode 100644 db/migrate/20241217141716_add_enrichment_setting.rb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 97b8de92..222ae018 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -26,10 +26,6 @@ class Settings::HostingsController < SettingsController Setting.synth_api_key = hosting_params[:synth_api_key] end - if hosting_params.key?(:data_enrichment_enabled) - Setting.data_enrichment_enabled = hosting_params[:data_enrichment_enabled] - end - redirect_to settings_hosting_path, notice: t(".success") rescue ActiveRecord::RecordInvalid => error flash.now[:alert] = t(".failure") @@ -38,7 +34,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, :data_enrichment_enabled) + params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :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 beb85197..55b75581 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -41,7 +41,7 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ] + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 9160e64f..df5a7b03 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -11,7 +11,7 @@ class Account::Syncer update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency - if Setting.data_enrichment_enabled || Rails.configuration.app_mode.managed? + if account.family.data_enrichment_enabled? account.enrich_data_later else Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") diff --git a/app/models/setting.rb b/app/models/setting.rb index fe047cbb..d576fbea 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -17,10 +17,6 @@ class Setting < RailsSettings::Base default: ENV.fetch("UPGRADES_TARGET", "release"), validates: { inclusion: { in: %w[release commit] } } - field :data_enrichment_enabled, - type: :boolean, - default: false - field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] field :require_invite_for_signup, type: :boolean, default: false diff --git a/app/views/settings/hostings/_data_enrichment_settings.html.erb b/app/views/settings/hostings/_data_enrichment_settings.html.erb deleted file mode 100644 index 6d409923..00000000 --- a/app/views/settings/hostings/_data_enrichment_settings.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -
-
-
-

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

-

<%= t(".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 :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> - <%= form.label :data_enrichment_enabled, " ".html_safe, class: "maybe-switch" %> -
- <% end %> -
-
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index a2af0bed..ba4b7d5d 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -10,7 +10,6 @@ <%= render "settings/hostings/upgrade_settings" %> <%= render "settings/hostings/provider_settings" %> <%= render "settings/hostings/synth_settings" %> - <%= render "settings/hostings/data_enrichment_settings" %>
<% end %> diff --git a/app/views/settings/preferences/_data_enrichment_settings.html.erb b/app/views/settings/preferences/_data_enrichment_settings.html.erb new file mode 100644 index 00000000..73f729fd --- /dev/null +++ b/app/views/settings/preferences/_data_enrichment_settings.html.erb @@ -0,0 +1,24 @@ +<%# locals: (user:) %> + +
+
+
+

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

+

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

+ <% if self_hosted? %> +

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

+ <% end %> +
+ + <%= styled_form_with model: user, + data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> +
+ <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= form.fields_for :family do |family_form| %> + <%= family_form.check_box :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %> + <%= family_form.label :data_enrichment_enabled, " ".html_safe, class: "maybe-switch" %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 225e5d96..8b066503 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -40,6 +40,11 @@ <% end %>
<% end %> + + <%= settings_section title: t(".data"), subtitle: t(".data_subtitle") do %> + <%= render "settings/preferences/data_enrichment_settings", user: @user %> + <% end %> + <%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<%= styled_form_with model: @user, class: "flex justify-between items-center" do |form| %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index aa8141b8..f784db97 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -26,9 +26,16 @@ en: next: Next previous: Back preferences: + data_enrichment_settings: + description: Let Maybe auto-categorize, name, and add merchant data to your + transactions on each sync. All enrichment is done in English. + self_host_disclaimer: This will incur Synth API credits. + title: Transaction enrichment (English only) show: country: Country currency: Currency + data: Data enrichment + data_subtitle: Enable data enrichment for your accounts date_format: Date format general_subtitle: Configure your preferences general_title: General diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index b3f04fc9..90a89fd7 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -2,10 +2,6 @@ en: settings: hostings: - data_enrichment_settings: - description: Enable data enrichment for your account transactions. This will - incur additional Synth credits. - title: Data Enrichment invite_code_settings: description: Every new user that joins your instance of Maybe can only do so via an invite code diff --git a/db/migrate/20241217141716_add_enrichment_setting.rb b/db/migrate/20241217141716_add_enrichment_setting.rb new file mode 100644 index 00000000..8d154887 --- /dev/null +++ b/db/migrate/20241217141716_add_enrichment_setting.rb @@ -0,0 +1,5 @@ +class AddEnrichmentSetting < ActiveRecord::Migration[7.2] + def change + add_column :families, :data_enrichment_enabled, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 5fd3f26d..5ae96e52 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: 2024_12_12_141453) do +ActiveRecord::Schema[7.2].define(version: 2024_12_17_141716) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -222,6 +222,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_12_141453) do t.string "country", default: "US" t.datetime "last_synced_at" t.string "timezone" + t.boolean "data_enrichment_enabled", default: false end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| From 7be6a372bf8648c1a432c6c7d1738fba368acbd6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 19 Dec 2024 10:16:09 -0500 Subject: [PATCH 082/626] Preserve original transaction names when enriching (#1556) * Preserve original transaction name * Remove stale method * Fix tests --- .../concerns/entryable_resource.rb | 2 +- app/models/account.rb | 1 + app/models/account/data_enricher.rb | 2 +- app/models/account/entry.rb | 14 ++---- app/models/account/syncer.rb | 3 +- app/models/account/trade.rb | 5 -- app/models/account/trade_builder.rb | 4 ++ app/models/account/transaction.rb | 4 -- app/models/account/transfer.rb | 4 +- app/models/account/valuation.rb | 4 -- app/models/demo/generator.rb | 1 + app/views/account/trades/_trade.html.erb | 6 +-- .../transactions/_transaction.html.erb | 14 +++--- app/views/account/transactions/show.html.erb | 3 +- app/views/account/valuations/_form.html.erb | 1 + .../account/valuations/_valuation.html.erb | 4 +- config/brakeman.ignore | 48 +++++++++---------- .../20241218132503_add_enriched_name_field.rb | 37 ++++++++++++++ db/schema.rb | 5 +- .../accountable_resource_interface_test.rb | 2 +- test/models/account/entry_test.rb | 6 --- test/system/trades_test.rb | 6 +-- 22 files changed, 100 insertions(+), 76 deletions(-) create mode 100644 db/migrate/20241218132503_add_enriched_name_field.rb diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 84aac1d4..918b32bb 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -119,7 +119,7 @@ module EntryableResource def entry_params params.require(:account_entry).permit( - :account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, + :account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, entryable_attributes: self.class.permitted_entryable_attributes ) end diff --git a/app/models/account.rb b/app/models/account.rb index 400e8bea..19d883ca 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -151,6 +151,7 @@ class Account < ApplicationRecord else entries.create! \ date: Date.current, + name: "Balance update", amount: balance, currency: currency, entryable: Account::Valuation.new diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb index e0615cc1..0be57dc1 100644 --- a/app/models/account/data_enricher.rb +++ b/app/models/account/data_enricher.rb @@ -50,7 +50,7 @@ class Account::DataEnricher category.save! if category.present? entry.update!( enriched_at: Time.current, - name: entry.enriched_at.nil? && info.name ? info.name : entry.name, + enriched_name: info.name, entryable_attributes: entryable_attributes ) end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 1801fb9e..4d7334fb 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -10,7 +10,7 @@ class Account::Entry < ApplicationRecord delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable - validates :date, :amount, :currency, presence: true + validates :date, :name, :amount, :currency, presence: true validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } @@ -47,14 +47,6 @@ class Account::Entry < ApplicationRecord account.sync_later(start_date: sync_start_date) end - def inflow? - amount <= 0 && account_transaction? - end - - def outflow? - amount > 0 && account_transaction? - end - def entryable_name_short entryable_type.demodulize.underscore end @@ -63,6 +55,10 @@ class Account::Entry < ApplicationRecord Account::BalanceTrendCalculator.new(self, entries, balances).trend end + def display_name + enriched_name.presence || name + end + class << self # arbitrary cutoff date to avoid expensive sync operations def min_supported_date diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index df5a7b03..5b2e4aba 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -11,7 +11,8 @@ class Account::Syncer update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency - if account.family.data_enrichment_enabled? + # Enrich if user opted in or if we're syncing transactions from a Plaid account + if account.family.data_enrichment_enabled? || account.plaid_account_id.present? account.enrich_data_later else Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index b8ebd7b8..70b0c8f3 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -26,11 +26,6 @@ class Account::Trade < ApplicationRecord qty > 0 end - def name - prefix = sell? ? "Sell " : "Buy " - prefix + "#{qty.abs} shares of #{security.ticker}" - end - def unrealized_gain_loss return nil if sell? current_price = security.current_price diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index 191d8100..e62947f7 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -31,7 +31,11 @@ class Account::TradeBuilder end def build_trade + prefix = type == "sell" ? "Sell " : "Buy " + trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}" + account.entries.new( + name: trade_name, date: date, amount: signed_amount, currency: currency, diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index fbf2aa9e..6b8f4995 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -48,10 +48,6 @@ class Account::Transaction < ApplicationRecord end end - def name - entry.name || (entry.amount.positive? ? "Expense" : "Income") - end - def eod_balance entry.amount_money end diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index 174576e8..ea908413 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -33,11 +33,11 @@ class Account::Transfer < ApplicationRecord end def inflow_transaction - entries.find { |e| e.inflow? } + entries.find { |e| e.amount.negative? } end def outflow_transaction - entries.find { |e| e.outflow? } + entries.find { |e| e.amount.positive? } end def update_entries!(params) diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index 5a4d1b8f..93ebf5ff 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -10,8 +10,4 @@ class Account::Valuation < ApplicationRecord false end end - - def name - "Balance update" - end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 29985a36..36e86200 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -303,6 +303,7 @@ class Demo::Generator date: date, amount: amount, currency: "USD", + name: "Balance update", entryable: Account::Valuation.new end diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index 2215cd94..8c5c924e 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -13,14 +13,14 @@
<%= tag.div class: ["flex items-center gap-2"] do %>
- <%= trade.name.first.upcase %> + <%= entry.display_name.first.upcase %>
<% if entry.new_record? %> - <%= content_tag :p, trade.name %> + <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to trade.name, + <%= link_to entry.display_name, account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index d63ddf8b..dfe0955a 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -11,17 +11,17 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %> - <% if entry.account_transaction.merchant&.icon_url %> - <%= image_tag entry.account_transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> + <% if transaction.merchant&.icon_url %> + <%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %> <% else %> - <%= render "shared/circle_logo", name: transaction.name, size: "sm" %> + <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %> <% end %>
<% if entry.new_record? %> - <%= content_tag :p, transaction.name %> + <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to transaction.name, + <%= link_to entry.display_name, entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> @@ -41,7 +41,7 @@ <% end %>
- <%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.outflow? %> + <%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.amount.positive? %>
<% else %>
@@ -65,7 +65,7 @@
<%= content_tag :p, format_money(-entry.amount_money), - class: ["text-green-600": entry.inflow?] %> + class: ["text-green-600": entry.amount.negative?] %>
<% if balance_trend %> diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index ecce8921..b512b43a 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -9,7 +9,8 @@ url: account_transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> - <%= f.text_field :name, + + <%= f.text_field @entry.enriched_at.present? ? :enriched_name : :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index b56e847b..f3f0aa5f 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -8,6 +8,7 @@ <% end %>
+ <%= form.hidden_field :name, value: "Balance update" %> <%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %> <%= form.money_field :amount, label: t(".amount"), required: true %>
diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index 0761fd17..3e34cfba 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -18,9 +18,9 @@
<% if entry.new_record? %> - <%= content_tag :p, entry.entryable.name %> + <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to entry.entryable.name, + <%= link_to entry.display_name, account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 6ebccac2..ce280c4d 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -23,6 +23,29 @@ ], "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "5bfdb129316655dc4e02f3a599156660414a6562212a5f61057d376f6888f078", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/concerns/entryable_resource.rb", + "line": 122, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:account_entry).permit(:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", + "render_path": null, + "location": { + "type": "method", + "class": "EntryableResource", + "method": "entry_params" + }, + "user_input": ":account_id", + "confidence": "High", + "cwe_id": [ + 915 + ], + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -80,29 +103,6 @@ ], "note": "" }, - { - "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "f158202dcc66f2273ddea5e5296bad7146a50ca6667f49c77372b5b234542334", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/concerns/entryable_resource.rb", - "line": 122, - "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:account_entry).permit(:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", - "render_path": null, - "location": { - "type": "method", - "class": "EntryableResource", - "method": "entry_params" - }, - "user_input": ":account_id", - "confidence": "High", - "cwe_id": [ - 915 - ], - "note": "" - }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -138,6 +138,6 @@ "note": "" } ], - "updated": "2024-11-27 15:33:53 -0500", + "updated": "2024-12-18 17:46:13 -0500", "brakeman_version": "6.2.2" } diff --git a/db/migrate/20241218132503_add_enriched_name_field.rb b/db/migrate/20241218132503_add_enriched_name_field.rb new file mode 100644 index 00000000..08d837c4 --- /dev/null +++ b/db/migrate/20241218132503_add_enriched_name_field.rb @@ -0,0 +1,37 @@ +class AddEnrichedNameField < ActiveRecord::Migration[7.2] + def change + add_column :account_entries, :enriched_name, :string + + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE account_entries ae + SET name = CASE ae.entryable_type + WHEN 'Account::Trade' THEN + CASE + WHEN EXISTS ( + SELECT 1 FROM account_trades t + WHERE t.id = ae.entryable_id AND t.qty < 0 + ) THEN 'Sell trade' + ELSE 'Buy trade' + END + WHEN 'Account::Transaction' THEN + CASE + WHEN ae.amount > 0 THEN 'Expense' + ELSE 'Income' + END + WHEN 'Account::Valuation' THEN 'Balance update' + ELSE 'Unknown entry' + END + WHERE name IS NULL + SQL + + change_column_null :account_entries, :name, false + end + + dir.down do + change_column_null :account_entries, :name, true + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 5ae96e52..7beb5097 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: 2024_12_17_141716) do +ActiveRecord::Schema[7.2].define(version: 2024_12_18_132503) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -39,7 +39,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_17_141716) do t.decimal "amount", precision: 19, scale: 4 t.string "currency" t.date "date" - t.string "name" + t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "transfer_id" @@ -49,6 +49,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_17_141716) do t.boolean "excluded", default: false t.string "plaid_id" t.datetime "enriched_at" + t.string "enriched_name" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index c1fe9208..d44baf7e 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -76,7 +76,7 @@ module AccountableResourceInterfaceTest end test "updates account balance by editing existing valuation for today" do - @account.entries.create! date: Date.current, amount: 6000, currency: "USD", entryable: Account::Valuation.new + @account.entries.create! date: Date.current, amount: 6000, currency: "USD", name: "Balance update", entryable: Account::Valuation.new assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do patch account_url(@account), params: { diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 9c541b6b..5a9d9ec9 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -90,10 +90,4 @@ class Account::EntryTest < ActiveSupport::TestCase assert_equal Money.new(-200), family.entries.income_total("USD") end - - # See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money - test "transactions with negative amounts are inflows, positive amounts are outflows to an account" do - assert create_transaction(amount: -10).inflow? - assert create_transaction(amount: 10).outflow? - end end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 9e26b708..9ca85c08 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -23,7 +23,7 @@ class TradesTest < ApplicationSystemTestCase end test "can create buy transaction" do - shares_qty = 25.0 + shares_qty = 25 open_new_trade_modal @@ -38,7 +38,7 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades within_trades do - assert_text "Buy 10.0 shares of AAPL" + assert_text "Purchase 10 shares of AAPL" assert_text "Buy #{shares_qty} shares of AAPL" end end @@ -60,7 +60,7 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades within_trades do - assert_text "Sell #{aapl.qty} shares of AAPL" + assert_text "Sell #{aapl.qty.round} shares of AAPL" end end From a4d10097d579cc383057b4d857a724cf22761106 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 20 Dec 2024 11:24:46 -0500 Subject: [PATCH 083/626] Preserve pagination on entry updates (#1563) * Preserve pagination on entry updates * Test fix --- app/helpers/application_helper.rb | 20 +++++++++++++++++ app/models/concerns/providable.rb | 6 ++--- app/views/account/entries/index.html.erb | 2 +- app/views/accounts/show/_activity.html.erb | 2 +- app/views/application/_pagination.html.erb | 26 ++++++++++++++-------- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 24c962df..85aca138 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -166,4 +166,24 @@ 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/models/concerns/providable.rb b/app/models/concerns/providable.rb index 4a8de8c0..996efff8 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -23,10 +23,8 @@ module Providable end def synth_provider - @synth_provider ||= begin - api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] - api_key.present? ? Provider::Synth.new(api_key) : nil - end + api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] + api_key.present? ? Provider::Synth.new(api_key) : nil end private diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index c659d97f..31197675 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -84,7 +84,7 @@
- <%= render "pagination", pagy: @pagy %> + <%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page]) %>
<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index c041b652..290c5be5 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %> +<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page]) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/application/_pagination.html.erb b/app/views/application/_pagination.html.erb index dee086be..f4548512 100644 --- a/app/views/application/_pagination.html.erb +++ b/app/views/application/_pagination.html.erb @@ -1,9 +1,11 @@ -<%# locals: (pagy:) %> +<%# locals: (pagy:, current_path: nil) %> From 77def1db404457c32e46797e68a420cc53819216 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 20 Dec 2024 11:37:26 -0500 Subject: [PATCH 084/626] Nested Categories (#1561) * Prepare entry search for nested categories * Subcategory implementation * Remove caching for test stability --- app/controllers/categories_controller.rb | 17 +++- app/controllers/registrations_controller.rb | 1 - app/controllers/transactions_controller.rb | 10 +-- app/models/account/entry.rb | 46 +--------- app/models/account/entry_search.rb | 59 +++++++++++++ app/models/account/trade.rb | 20 +---- app/models/account/transaction.rb | 47 +---------- app/models/account/transaction_search.rb | 42 ++++++++++ app/models/account/valuation.rb | 10 --- app/models/category.rb | 84 +++++++++++++------ app/models/demo/generator.rb | 5 ++ app/views/account/trades/_header.html.erb | 4 +- app/views/categories/_badge.html.erb | 2 +- app/views/categories/_category.html.erb | 6 +- app/views/categories/_form.html.erb | 13 ++- app/views/categories/_menu.html.erb | 2 +- app/views/categories/edit.html.erb | 2 +- app/views/categories/index.html.erb | 28 +++++-- app/views/categories/new.html.erb | 2 +- app/views/category/dropdowns/_row.html.erb | 3 + app/views/category/dropdowns/show.html.erb | 17 +++- config/locales/models/transaction/en.yml | 12 --- config/locales/views/categories/en.yml | 13 ++- config/routes.rb | 2 + .../20241219174803_add_parent_category.rb | 6 ++ db/schema.rb | 6 +- .../controllers/categories_controller_test.rb | 14 +++- .../registrations_controller_test.rb | 9 -- test/fixtures/categories.yml | 7 +- test/models/account/entry_test.rb | 4 - test/models/category_test.rb | 38 +++------ 31 files changed, 297 insertions(+), 234 deletions(-) create mode 100644 app/models/account/entry_search.rb create mode 100644 app/models/account/transaction_search.rb delete mode 100644 config/locales/models/transaction/en.yml create mode 100644 db/migrate/20241219174803_add_parent_category.rb diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index ddc1ecaa..2d1882f4 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -10,6 +10,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) end def create @@ -17,19 +18,21 @@ class CategoriesController < ApplicationController if @category.save @transaction.update(category_id: @category.id) if @transaction - redirect_back_or_to transactions_path, notice: t(".success") + + redirect_back_or_to categories_path, notice: t(".success") else - redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence) + 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 - redirect_back_or_to transactions_path, notice: t(".success") + redirect_back_or_to categories_path, notice: t(".success") end def destroy @@ -38,6 +41,12 @@ class CategoriesController < ApplicationController redirect_back_or_to categories_path, notice: t(".success") end + def bootstrap + Current.family.categories.bootstrap_defaults + + redirect_back_or_to categories_path, notice: t(".success") + end + private def set_category @category = Current.family.categories.find(params[:id]) @@ -50,6 +59,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color) + params.require(:category).permit(:name, :color, :parent_id) end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 128b309c..0d8d6e92 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -24,7 +24,6 @@ class RegistrationsController < ApplicationController if @user.save @invitation&.update!(accepted_at: Time.current) - Category.create_default_categories(@user.family) unless @invitation @session = create_session_for(@user) redirect_to root_path, notice: t(".success") else diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index acceab79..664c3080 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -3,13 +3,13 @@ class TransactionsController < ApplicationController def index @q = search_params - result = Current.family.entries.account_transactions.search(@q).reverse_chronological - @pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50") + search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological + @pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") @totals = { - count: result.select { |t| t.currency == Current.family.currency }.count, - income: result.income_total(Current.family.currency).abs, - expense: result.expense_total(Current.family.currency) + count: search_query.select { |t| t.currency == Current.family.currency }.count, + income: search_query.income_total(Current.family.currency).abs, + expense: search_query.expense_total(Current.family.currency) } end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 4d7334fb..8d6a8f40 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -60,6 +60,10 @@ class Account::Entry < ApplicationRecord end class << self + def search(params) + Account::EntrySearch.new(params).build_query(all) + end + # arbitrary cutoff date to avoid expensive sync operations def min_supported_date 30.years.ago.to_date @@ -141,49 +145,7 @@ class Account::Entry < ApplicationRecord Money.new(total, currency) end - def search(params) - query = all - query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present? - query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present? - query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present? - - if params[:types].present? - query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer") - - if params[:types].include?("income") && !params[:types].include?("expense") - query = query.where("account_entries.amount < 0") - elsif params[:types].include?("expense") && !params[:types].include?("income") - query = query.where("account_entries.amount >= 0") - end - end - - if params[:amount].present? && params[:amount_operator].present? - case params[:amount_operator] - when "equal" - query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs) - when "less" - query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs) - when "greater" - query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs) - end - end - - if params[:accounts].present? || params[:account_ids].present? - query = query.joins(:account) - end - - query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present? - query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present? - - # Search attributes on each entryable to further refine results - entryable_ids = entryable_search(params) - query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil? - - query - end - private - def entryable_search(params) entryable_ids = [] entryable_search_performed = false diff --git a/app/models/account/entry_search.rb b/app/models/account/entry_search.rb new file mode 100644 index 00000000..c561765b --- /dev/null +++ b/app/models/account/entry_search.rb @@ -0,0 +1,59 @@ +class Account::EntrySearch + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :search, :string + attribute :amount, :string + attribute :amount_operator, :string + attribute :types, :string + attribute :accounts, :string + attribute :account_ids, :string + attribute :start_date, :string + attribute :end_date, :string + + class << self + def from_entryable_search(entryable_search) + new(entryable_search.attributes.slice(*attribute_names)) + end + end + + def build_query(scope) + query = scope + + query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search", + search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" + ) if search.present? + query = query.where("account_entries.date >= ?", start_date) if start_date.present? + query = query.where("account_entries.date <= ?", end_date) if end_date.present? + + if types.present? + query = query.where(marked_as_transfer: false) unless types.include?("transfer") + + if types.include?("income") && !types.include?("expense") + query = query.where("account_entries.amount < 0") + elsif types.include?("expense") && !types.include?("income") + query = query.where("account_entries.amount >= 0") + end + end + + if amount.present? && amount_operator.present? + case amount_operator + when "equal" + query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs) + when "less" + query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs) + when "greater" + query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs) + end + end + + if accounts.present? || account_ids.present? + query = query.joins(:account) + end + + query = query.where(accounts: { name: accounts }) if accounts.present? + query = query.where(accounts: { id: account_ids }) if account_ids.present? + + query + end +end diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index 70b0c8f3..7d4976ba 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -8,26 +8,8 @@ class Account::Trade < ApplicationRecord validates :qty, presence: true validates :price, :currency, presence: true - class << self - def search(_params) - all - end - - def requires_search?(_params) - false - end - end - - def sell? - qty < 0 - end - - def buy? - qty > 0 - end - def unrealized_gain_loss - return nil if sell? + return nil if qty.negative? current_price = security.current_price return nil if current_price.nil? diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index 6b8f4995..afe5a568 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -12,52 +12,7 @@ class Account::Transaction < ApplicationRecord class << self def search(params) - query = all - if params[:categories].present? - if params[:categories].exclude?("Uncategorized") - query = query - .joins(:category) - .where(categories: { name: params[:categories] }) - else - query = query - .left_joins(:category) - .where(categories: { name: params[:categories] }) - .or(query.where(category_id: nil)) - end - end - - query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present? - - if params[:tags].present? - query = query.joins(:tags) - .where(tags: { name: params[:tags] }) - .distinct - end - - query + Account::TransactionSearch.new(params).build_query(all) end - - def requires_search?(params) - searchable_keys.any? { |key| params.key?(key) } - end - - private - - def searchable_keys - %i[categories merchants tags] - end end - - def eod_balance - entry.amount_money - end - - private - def account - entry.account - end - - def daily_transactions - account.entries.account_transactions - end end diff --git a/app/models/account/transaction_search.rb b/app/models/account/transaction_search.rb new file mode 100644 index 00000000..f61fae69 --- /dev/null +++ b/app/models/account/transaction_search.rb @@ -0,0 +1,42 @@ +class Account::TransactionSearch + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :search, :string + attribute :amount, :string + attribute :amount_operator, :string + attribute :types, array: true + attribute :accounts, array: true + attribute :account_ids, array: true + attribute :start_date, :string + attribute :end_date, :string + attribute :categories, array: true + attribute :merchants, array: true + attribute :tags, array: true + + # Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry + def build_query(scope) + query = scope + + if categories.present? + if categories.exclude?("Uncategorized") + query = query + .joins(:category) + .where(categories: { name: categories }) + else + query = query + .left_joins(:category) + .where(categories: { name: categories }) + .or(query.where(category_id: nil)) + end + end + + query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present? + + query = query.joins(:tags).where(tags: { name: tags }) if tags.present? + + entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id)) + + Account::EntrySearch.from_entryable_search(self).build_query(entries_scope) + end +end diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index 93ebf5ff..219ecd90 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -1,13 +1,3 @@ class Account::Valuation < ApplicationRecord include Account::Entryable - - class << self - def search(_params) - all - end - - def requires_search?(_params) - false - end - end end diff --git a/app/models/category.rb b/app/models/category.rb index 4a2d6361..6f50070b 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,12 +1,16 @@ class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" + belongs_to :family + has_many :subcategories, class_name: "Category", foreign_key: :parent_id + belongs_to :parent, class_name: "Category", optional: true + validates :name, :color, :family, presence: true validates :name, uniqueness: { scope: :family_id } - before_update :clear_internal_category, if: :name_changed? + validate :category_level_limit scope :alphabetically, -> { order(:name) } @@ -14,30 +18,55 @@ class Category < ApplicationRecord UNCATEGORIZED_COLOR = "#737373" - DEFAULT_CATEGORIES = [ - { internal_category: "income", color: COLORS[0] }, - { internal_category: "food_and_drink", color: COLORS[1] }, - { internal_category: "entertainment", color: COLORS[2] }, - { internal_category: "personal_care", color: COLORS[3] }, - { internal_category: "general_services", color: COLORS[4] }, - { internal_category: "auto_and_transport", color: COLORS[5] }, - { internal_category: "rent_and_utilities", color: COLORS[6] }, - { internal_category: "home_improvement", color: COLORS[7] } - ] + class Group + attr_reader :category, :subcategories - def self.create_default_categories(family) - if family.categories.size > 0 - raise ArgumentError, "Family already has some categories" + delegate :name, :color, to: :category + + def self.for(categories) + categories.select { |category| category.parent_id.nil? }.map do |category| + new(category, category.subcategories) + end end - family_id = family.id - categories = self::DEFAULT_CATEGORIES.map { |c| { - name: I18n.t("transaction.default_category.#{c[:internal_category]}"), - internal_category: c[:internal_category], - color: c[:color], - family_id: - } } - self.insert_all(categories) + def initialize(category, subcategories = nil) + @category = category + @subcategories = subcategories || [] + end + end + + class << self + def bootstrap_defaults + default_categories.each do |name, color| + find_or_create_by!(name: name) do |category| + category.color = color + end + end + end + + private + def default_categories + [ + [ "Income", "#e99537" ], + [ "Loan Payments", "#6471eb" ], + [ "Bank Fees", "#db5a54" ], + [ "Entertainment", "#df4e92" ], + [ "Food & Drink", "#c44fe9" ], + [ "Groceries", "#eb5429" ], + [ "Dining Out", "#61c9ea" ], + [ "General Merchandise", "#805dee" ], + [ "Clothing & Accessories", "#6ad28a" ], + [ "Electronics", "#e99537" ], + [ "Healthcare", "#4da568" ], + [ "Insurance", "#6471eb" ], + [ "Utilities", "#db5a54" ], + [ "Transportation", "#df4e92" ], + [ "Gas & Fuel", "#c44fe9" ], + [ "Education", "#eb5429" ], + [ "Charitable Donations", "#61c9ea" ], + [ "Subscriptions", "#805dee" ] + ] + end end def replace_and_destroy!(replacement) @@ -47,9 +76,14 @@ class Category < ApplicationRecord end end - private + def subcategory? + parent.present? + end - def clear_internal_category - self.internal_category = nil + private + def category_level_limit + if subcategory? && parent.subcategory? + errors.add(:parent, "can't have more than 2 levels of subcategories") + end end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 36e86200..d26b85c7 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -90,6 +90,11 @@ class Demo::Generator categories.each do |category| family.categories.create!(name: category, color: COLORS.sample) end + + food = family.categories.find_by(name: "Food & Drink") + family.categories.create!(name: "Restaurants", parent_category: food) + family.categories.create!(name: "Groceries", parent_category: food) + family.categories.create!(name: "Alcohol & Bars", parent_category: food) end def create_merchants! diff --git a/app/views/account/trades/_header.html.erb b/app/views/account/trades/_header.html.erb index 7ccadfa4..b89028af 100644 --- a/app/views/account/trades/_header.html.erb +++ b/app/views/account/trades/_header.html.erb @@ -34,7 +34,7 @@
<%= trade.security.ticker %>
- <% if trade.buy? %> + <% if trade.qty.positive? %>
<%= t(".purchase_qty_label") %>
<%= trade.qty.abs %>
@@ -53,7 +53,7 @@
<% end %> - <% if trade.buy? && trade.unrealized_gain_loss.present? %> + <% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>
<%= t(".total_return_label") %>
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index a9752262..1b4c399b 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -2,7 +2,7 @@ <% category ||= null_category %>
- " class="flex justify-between items-center p-4 bg-white"> +
<%= "pb-4" unless category.subcategories.any? %> bg-white">
+ <% if category.subcategory? %> + <%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400 ml-2" %> + <% end %> + <%= render partial: "categories/badge", locals: { category: category } %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 313e48ba..2bca2191 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,9 +1,12 @@ +<%# locals: (category:, categories:) %> +
<%= 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 } %>
+
<% Category::COLORS.each do |color| %> <% end %>
-
- <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, data: { color_avatar_target: "name" } %> + + <% if category.errors.any? %> + <%= render "shared/form_errors", model: category %> + <% end %> + +
+ <%= 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)" } %>
diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb index 746bcb45..5a4627b3 100644 --- a/app/views/categories/_menu.html.erb +++ b/app/views/categories/_menu.html.erb @@ -5,7 +5,7 @@ <%= render partial: "categories/badge", locals: { category: transaction.category } %>
diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb new file mode 100644 index 00000000..05b8db3b --- /dev/null +++ b/app/views/transfers/_transfer.html.erb @@ -0,0 +1,77 @@ +<%# locals: (transfer:) %> + +<%= turbo_frame_tag dom_id(transfer) do %> +
+
+ <%= check_box_tag dom_id(transfer), + disabled: true, + class: "maybe-checkbox maybe-checkbox--light" %> + +
+ <%= content_tag :div, class: ["flex items-center gap-2"] do %> + <%= render "shared/circle_logo", name: transfer.name, size: "sm" %> + +
+
+ +
+ <%= link_to transfer.name, + transfer_path(transfer), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "hover:underline hover:text-gray-800" %> + + <% if transfer.status == "confirmed" %> + is confirmed"> + <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %> + + <% elsif transfer.status == "rejected" %> + + Rejected + + <% else %> + + Auto-matched + + + <%= button_to transfer_path(transfer, transfer: { status: "confirmed" }), + method: :patch, + class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", + title: "Confirm match" do %> + <%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %> + <% end %> + + <%= button_to transfer_path(transfer, transfer: { status: "rejected" }), + method: :patch, + 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" %> + <% end %> + <% end %> +
+ +
+
+ <%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %> + <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %> +
+
+
+
+ <% end %> +
+
+ +
+ <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> +
+ +
+

+ + +/- <%= format_money(transfer.amount_abs) %> + +

+
+
+<% end %> diff --git a/app/views/account/transfers/new.html.erb b/app/views/transfers/new.html.erb similarity index 100% rename from app/views/account/transfers/new.html.erb rename to app/views/transfers/new.html.erb diff --git a/app/views/account/transfers/show.html.erb b/app/views/transfers/show.html.erb similarity index 63% rename from app/views/account/transfers/show.html.erb rename to app/views/transfers/show.html.erb index 53e37ecf..f8307997 100644 --- a/app/views/account/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -3,11 +3,11 @@

- <%= format_money @transfer.amount_money %> + <%= format_money @transfer.amount_abs %> - <%= @transfer.amount_money.currency.iso_code %> + <%= @transfer.amount_abs.currency.iso_code %>

@@ -25,21 +25,21 @@
-
To
+
From
- <%= render "accounts/logo", account: @transfer.inflow_transaction.account, size: "sm" %> - <%= @transfer.to_name %> + <%= render "accounts/logo", account: @transfer.from_account, size: "sm" %> + <%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
Date
-
<%= l(@transfer.date, format: :long) %>
+
<%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
Amount
-
<%= format_money -@transfer.amount_money %>
+
<%= format_money -@transfer.amount_abs %>
@@ -47,21 +47,21 @@
-
From
+
To
- <%= render "accounts/logo", account: @transfer.outflow_transaction.account, size: "sm" %> - <%= @transfer.from_name %> + <%= render "accounts/logo", account: @transfer.to_account, size: "sm" %> + <%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
Date
-
<%= l(@transfer.date, format: :long) %>
+
<%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
Amount
-
+<%= format_money @transfer.amount_money %>
+
+<%= format_money @transfer.amount_abs %>
@@ -74,7 +74,6 @@ <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), - value: @transfer.outflow_transaction.notes, rows: 5, "data-auto-submit-form-target": "auto" %> <% end %> @@ -83,25 +82,6 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: @transfer, - class: "p-3", data: { controller: "auto-submit-form" } do |f| %> -
-
-

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

-

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

-
- -
- <%= f.check_box :excluded, - checked: @transfer.inflow_transaction.excluded, - class: "sr-only peer", - "data-auto-submit-form-target": "auto" %> - -
-
- <% end %> -

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

@@ -109,9 +89,9 @@
<%= button_to t(".delete"), - account_transfer_path(@transfer), + transfer_path(@transfer), method: :delete, - class: "rounded-lg px-3 py-2 text-red-500 text-sm + class: "rounded-lg px-3 py-2 whitespace-nowrap text-red-500 text-sm font-medium border border-alpha-black-200", data: { turbo_confirm: true, turbo_frame: "_top" } %>
diff --git a/app/views/transfers/update.turbo_stream.erb b/app/views/transfers/update.turbo_stream.erb new file mode 100644 index 00000000..90d5a7d5 --- /dev/null +++ b/app/views/transfers/update.turbo_stream.erb @@ -0,0 +1,17 @@ +<%= turbo_stream.replace @transfer %> + +<%= 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}", + partial: "account/transactions/transaction_category", + locals: { entry: @transfer.outflow_transaction.entry } %> + +<%= 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}", + partial: "account/transactions/transfer_match", + locals: { entry: @transfer.outflow_transaction.entry } %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index ce280c4d..65697755 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -103,6 +103,30 @@ ], "note": "" }, + { + "warning_type": "Dangerous Eval", + "warning_code": 13, + "fingerprint": "c193307bb82f931950d3bf2855f82f9a7f50d94c5bd950ee2803cb8a8abe5253", + "check_name": "Evaluation", + "message": "Dynamic string evaluated as code", + "file": "app/helpers/styled_form_builder.rb", + "line": 7, + "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/", + "code": "class_eval(\" def #{selector}(method, options = {})\\n merged_options = { class: \\\"form-field__input\\\" }.merge(options)\\n label = build_label(method, options)\\n field = super(method, merged_options)\\n\\n build_styled_field(label, field, merged_options)\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (7 + 1))", + "render_path": null, + "location": { + "type": "method", + "class": "StyledFormBuilder", + "method": null + }, + "user_input": null, + "confidence": "Weak", + "cwe_id": [ + 913, + 95 + ], + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -138,6 +162,5 @@ "note": "" } ], - "updated": "2024-12-18 17:46:13 -0500", - "brakeman_version": "6.2.2" + "brakeman_version": "7.0.0" } diff --git a/config/locales/models/account/transfer/en.yml b/config/locales/models/account/transfer/en.yml deleted file mode 100644 index c0fe38d5..00000000 --- a/config/locales/models/account/transfer/en.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -en: - account/transfer: - from_fallback_name: Originator - name: Transfer from %{from_account} to %{to_account} - to_fallback_name: Receiver - activerecord: - errors: - models: - account/transfer: - attributes: - entries: - must_be_from_different_accounts: must be from different accounts - must_be_marked_as_transfer: must be marked as transfer - must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow - and outflow that net to zero - must_have_exactly_2_entries: must have exactly 2 entries diff --git a/config/locales/models/transfer/en.yml b/config/locales/models/transfer/en.yml new file mode 100644 index 00000000..f373cc8e --- /dev/null +++ b/config/locales/models/transfer/en.yml @@ -0,0 +1,17 @@ +--- +en: + activerecord: + errors: + models: + transfer: + attributes: + base: + must_be_from_different_accounts: Transfer must have different accounts + 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 + transfer: + name: Transfer to %{to_account} + payment_name: Payment to %{to_account} diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index af05bcdf..659c5779 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -36,15 +36,8 @@ en: no_transactions: No transactions for this account yet. transaction: transaction transactions: Transactions - mark_transfers: - success: Marked as transfers new: new_transaction: New transaction - selection_bar: - mark_transfers: Mark as transfers? - mark_transfers_confirm: Mark as transfers - mark_transfers_message: By marking transactions as transfers, they will no - longer be included in income or spending calculations. show: account_label: Account amount: Amount @@ -55,9 +48,6 @@ en: balances, and cannot be undone. delete_title: Delete transaction details: Details - exclude_subtitle: This excludes the transaction from any in-app features or - analytics. - exclude_title: Exclude transaction merchant_label: Merchant name_label: Name nature: Type @@ -68,5 +58,6 @@ en: settings: Settings tags_label: Tags uncategorized: "(uncategorized)" - unmark_transfers: - success: Transfer removed + transfer_matches: + create: + success: Transfer created diff --git a/config/locales/views/account/transfers/en.yml b/config/locales/views/account/transfers/en.yml deleted file mode 100644 index 7728d764..00000000 --- a/config/locales/views/account/transfers/en.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -en: - account: - transfers: - create: - success: Transfer created - destroy: - success: Transfer removed - form: - amount: Amount - date: Date - expense: Expense - from: From - income: Income - select_account: Select account - submit: Create transfer - to: To - transfer: Transfer - new: - title: New transfer - show: - delete: Delete - delete_subtitle: This permanently deletes both of the transactions related - to the transfer. This cannot be undone. - delete_title: Delete transfer? - details: Details - exclude_subtitle: This excludes the transfer from any in-app features or analytics. - exclude_title: Exclude transfer - note_label: Notes - note_placeholder: Add a note to this transfer - overview: Overview - settings: Settings - transfer_toggle: - remove_transfer: Remove transfer - remove_transfer_body: This will remove the transfer from this transaction - remove_transfer_confirm: Confirm - update: - success: Transfer updated diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 7951e328..1d6aeb6e 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -1,11 +1,8 @@ --- en: - category: - dropdowns: - show: - empty: No categories found - bootstrap: Generate default categories categories: + bootstrap: + success: Default categories created successfully category: delete: Delete category edit: Edit category @@ -18,15 +15,18 @@ en: form: placeholder: Category name index: + bootstrap: Use default categories categories: Categories empty: No categories found new: New category - bootstrap: Use default categories - bootstrap: - success: Default categories created successfully menu: loading: Loading... new: new_category: New category update: success: Category updated successfully + category: + dropdowns: + show: + bootstrap: Generate default categories + empty: No categories found diff --git a/config/locales/views/category/dropdowns/en.yml b/config/locales/views/category/dropdowns/en.yml index 5b9e8248..511e86a9 100644 --- a/config/locales/views/category/dropdowns/en.yml +++ b/config/locales/views/category/dropdowns/en.yml @@ -6,7 +6,6 @@ en: delete: Delete category edit: Edit category show: - add_new: Add new - clear: Clear + clear: Clear category no_categories: No categories found search_placeholder: Search diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml new file mode 100644 index 00000000..669bae2d --- /dev/null +++ b/config/locales/views/transfers/en.yml @@ -0,0 +1,31 @@ +--- +en: + transfers: + create: + success: Transfer created + destroy: + success: Transfer removed + form: + amount: Amount + date: Date + expense: Expense + from: From + income: Income + select_account: Select account + submit: Create transfer + to: To + transfer: Transfer + new: + title: New transfer + show: + delete: Remove transfer + delete_subtitle: This removes the transfer. It will not delete the underlying + transactions. + delete_title: Remove transfer? + details: Details + note_label: Notes + note_placeholder: Add a note to this transfer + overview: Overview + settings: Settings + update: + success: Transfer updated diff --git a/config/routes.rb b/config/routes.rb index 73673ced..440756ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,9 +46,7 @@ Rails.application.routes.draw do resources :merchants, only: %i[index new create edit update destroy] - namespace :account do - resources :transfers, only: %i[new create destroy show update] - end + resources :transfers, only: %i[new create destroy show update] resources :imports, only: %i[index new show create destroy] do post :publish, on: :member @@ -81,6 +79,7 @@ Rails.application.routes.draw do 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 collection do diff --git a/db/migrate/20241231140709_reverse_transfer_relations.rb b/db/migrate/20241231140709_reverse_transfer_relations.rb new file mode 100644 index 00000000..5dbfe795 --- /dev/null +++ b/db/migrate/20241231140709_reverse_transfer_relations.rb @@ -0,0 +1,75 @@ +class ReverseTransferRelations < ActiveRecord::Migration[7.2] + def change + create_table :transfers, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade 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.string :status, null: false, default: "pending" + t.text :notes + + t.index [ :inflow_transaction_id, :outflow_transaction_id ], unique: true + t.timestamps + end + + reversible do |dir| + dir.up do + execute <<~SQL + INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at) + SELECT + CASE WHEN e1.amount <= 0 THEN e1.entryable_id ELSE e2.entryable_id END as inflow_transaction_id, + CASE WHEN e1.amount <= 0 THEN e2.entryable_id ELSE e1.entryable_id END as outflow_transaction_id, + 'confirmed' as status, + e1.created_at, + e1.updated_at + FROM account_entries e1 + JOIN account_entries e2 ON + e1.transfer_id = e2.transfer_id AND + e1.id != e2.id AND + e1.id < e2.id -- Ensures we don't duplicate transfers from both sides + JOIN accounts a1 ON e1.account_id = a1.id + JOIN accounts a2 ON e2.account_id = a2.id + WHERE + e1.entryable_type = 'Account::Transaction' AND + e2.entryable_type = 'Account::Transaction' AND + e1.transfer_id IS NOT NULL AND + a1.family_id = a2.family_id; + SQL + end + + dir.down do + execute <<~SQL + WITH new_transfers AS ( + INSERT INTO account_transfers (created_at, updated_at) + SELECT created_at, updated_at + FROM transfers + RETURNING id, created_at + ), + transfer_pairs AS ( + SELECT + nt.id as transfer_id, + ae_in.id as inflow_entry_id, + ae_out.id as outflow_entry_id + FROM transfers t + JOIN new_transfers nt ON nt.created_at = t.created_at + JOIN account_entries ae_in ON ae_in.entryable_id = t.inflow_transaction_id + JOIN account_entries ae_out ON ae_out.entryable_id = t.outflow_transaction_id + WHERE + ae_in.entryable_type = 'Account::Transaction' AND + ae_out.entryable_type = 'Account::Transaction' + ) + UPDATE account_entries ae + SET transfer_id = tp.transfer_id + FROM transfer_pairs tp + WHERE ae.id IN (tp.inflow_entry_id, tp.outflow_entry_id); + SQL + end + end + + remove_foreign_key :account_entries, :account_transfers, column: :transfer_id + remove_column :account_entries, :transfer_id, :uuid + remove_column :account_entries, :marked_as_transfer, :boolean + + drop_table :account_transfers, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0bdce796..7c333cc3 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: 2024_12_27_142333) do +ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -42,8 +42,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "transfer_id" - t.boolean "marked_as_transfer", default: false, null: false t.uuid "import_id" t.text "notes" t.boolean "excluded", default: false @@ -52,7 +50,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.string "enriched_name" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" - t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" end create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -89,11 +86,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id" end - create_table "account_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -606,6 +598,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.index ["family_id"], name: "index_tags_on_family_id" end + create_table "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.string "status", default: "pending", null: false + t.text "notes" + 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_8cd07a28bd", unique: true + t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" + t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id" + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "first_name" @@ -634,7 +638,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do end add_foreign_key "account_balances", "accounts", on_delete: :cascade - add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "accounts" add_foreign_key "account_entries", "imports" add_foreign_key "account_holdings", "accounts" @@ -663,5 +666,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) 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 "users", "families" end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index cdfd6add..66ab86ed 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -38,7 +38,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, - -> { Account::Transfer.count } => 1 do + -> { Transfer.count } => 1 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -59,7 +59,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, - -> { Account::Transfer.count } => 1 do + -> { Transfer.count } => 1 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -78,7 +78,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "deposit and withdrawal has optional transfer account" do assert_difference -> { Account::Entry.count } => 1, -> { Account::Transaction.count } => 1, - -> { Account::Transfer.count } => 0 do + -> { Transfer.count } => 0 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -93,7 +93,6 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest created_entry = Account::Entry.order(created_at: :desc).first assert created_entry.amount.positive? - assert created_entry.marked_as_transfer assert_redirected_to @entry.account end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index d490bfa7..1b077eb2 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest end test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions + transactions = @user.family.entries.account_transactions.incomes_and_expenses delete_count = transactions.size assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do diff --git a/test/controllers/account/transfer_matches_controller_test.rb b/test/controllers/account/transfer_matches_controller_test.rb new file mode 100644 index 00000000..0e13883e --- /dev/null +++ b/test/controllers/account/transfer_matches_controller_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Account::TransferMatchesControllerTest < ActionDispatch::IntegrationTest + include Account::EntriesTestHelper + + setup do + sign_in @user = users(:family_admin) + end + + test "matches existing transaction and creates transfer" do + inflow_transaction = create_transaction(amount: 100, account: accounts(:depository)) + outflow_transaction = create_transaction(amount: -100, account: accounts(:investment)) + + assert_difference "Transfer.count", 1 do + post account_transaction_transfer_match_path(inflow_transaction), params: { + transfer_match: { + method: "existing", + matched_entry_id: outflow_transaction.id + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer created", flash[:notice] + end + + test "creates transfer for target account" do + inflow_transaction = create_transaction(amount: 100, account: accounts(:depository)) + + assert_difference [ "Transfer.count", "Account::Entry.count", "Account::Transaction.count" ], 1 do + post account_transaction_transfer_match_path(inflow_transaction), params: { + transfer_match: { + method: "new", + target_account_id: accounts(:investment).id + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer created", flash[:notice] + end +end diff --git a/test/controllers/account/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb similarity index 55% rename from test/controllers/account/transfers_controller_test.rb rename to test/controllers/transfers_controller_test.rb index 72e14345..391937e8 100644 --- a/test/controllers/account/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -1,19 +1,19 @@ require "test_helper" -class Account::TransfersControllerTest < ActionDispatch::IntegrationTest +class TransfersControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) end test "should get new" do - get new_account_transfer_url + get new_transfer_url assert_response :success end test "can create transfers" do - assert_difference "Account::Transfer.count", 1 do - post account_transfers_url, params: { - account_transfer: { + assert_difference "Transfer.count", 1 do + post transfers_url, params: { + transfer: { from_account_id: accounts(:depository).id, to_account_id: accounts(:credit_card).id, date: Date.current, @@ -26,8 +26,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest end test "can destroy transfer" do - assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => -2 do - delete account_transfer_url(account_transfers(:one)) + assert_difference -> { Transfer.count } => -1, -> { Account::Transaction.count } => 0 do + delete transfer_url(transfers(:one)) end end end diff --git a/test/fixtures/account/entries.yml b/test/fixtures/account/entries.yml index 680710be..ccaf29bf 100644 --- a/test/fixtures/account/entries.yml +++ b/test/fixtures/account/entries.yml @@ -31,8 +31,6 @@ transfer_out: amount: 100 currency: USD account: depository - marked_as_transfer: true - transfer: one entryable_type: Account::Transaction entryable: transfer_out @@ -42,7 +40,5 @@ transfer_in: amount: -100 currency: USD account: credit_card - marked_as_transfer: true - transfer: one entryable_type: Account::Transaction entryable: transfer_in diff --git a/test/fixtures/account/transfers.yml b/test/fixtures/account/transfers.yml deleted file mode 100644 index 6aab7788..00000000 --- a/test/fixtures/account/transfers.yml +++ /dev/null @@ -1 +0,0 @@ -one: { } diff --git a/test/fixtures/transfers.yml b/test/fixtures/transfers.yml new file mode 100644 index 00000000..90c1ea94 --- /dev/null +++ b/test/fixtures/transfers.yml @@ -0,0 +1,3 @@ +one: + inflow_transaction: transfer_in + outflow_transaction: transfer_out diff --git a/test/models/account/transfer_test.rb b/test/models/account/transfer_test.rb deleted file mode 100644 index 2c9265c6..00000000 --- a/test/models/account/transfer_test.rb +++ /dev/null @@ -1,69 +0,0 @@ -require "test_helper" - -class Account::TransferTest < ActiveSupport::TestCase - setup do - @outflow = account_entries(:transfer_out) - @inflow = account_entries(:transfer_in) - end - - test "transfer valid if it has inflow and outflow from different accounts for the same amount" do - transfer = Account::Transfer.create! entries: [ @inflow, @outflow ] - - assert transfer.valid? - end - - test "transfer must have 2 transactions" do - invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ] - invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ] - - assert invalid_transfer_1.invalid? - assert invalid_transfer_2.invalid? - end - - test "transfer cannot have 2 transactions from the same account" do - account = accounts(:depository) - - inflow = account.entries.create! \ - date: Date.current, - name: "Inflow", - amount: -100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new - - outflow = account.entries.create! \ - date: Date.current, - name: "Outflow", - amount: 100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new - - assert_raise ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ inflow, outflow ] - end - end - - test "all transfer transactions must be marked as transfers" do - @inflow.update! marked_as_transfer: false - - assert_raise ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ @inflow, @outflow ] - end - end - - test "single-currency transfer transactions must net to zero" do - @outflow.update! amount: 105 - - assert_raises ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ @inflow, @outflow ] - end - end - - test "multi-currency transfer transactions do not have to net to zero" do - @outflow.update! amount: 105, currency: "EUR" - transfer = Account::Transfer.create! entries: [ @inflow, @outflow ] - - assert transfer.valid? - end -end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 74376a7e..33088c81 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -120,11 +120,9 @@ class FamilyTest < ActiveSupport::TestCase test "calculates rolling transaction totals" do account = create_account(balance: 1000, accountable: Depository.new) - liability_account = create_account(balance: 1000, accountable: Loan.new) create_transaction(account: account, date: 2.days.ago.to_date, amount: -500) create_transaction(account: account, date: 1.day.ago.to_date, amount: 100) create_transaction(account: account, date: Date.current, amount: 20) - create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333) snapshot = @family.snapshot_transactions diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb new file mode 100644 index 00000000..a4460fd2 --- /dev/null +++ b/test/models/transfer_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class TransferTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @outflow = account_transactions(:transfer_out) + @inflow = account_transactions(:transfer_in) + end + + test "transfer has different accounts, opposing amounts, and within 4 days of each other" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500) + + assert_difference -> { Transfer.count } => 1 do + Transfer.create!( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + end + end + + test "transfer cannot have 2 transactions from the same account" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: -500) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer must have different accounts", transfer.errors.full_messages.first + end + + test "Transfer transactions must have opposite amounts" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -400) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer transactions must have opposite amounts", transfer.errors.full_messages.first + end + + test "transfer dates must be within 4 days of each other" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 5.days.ago.to_date, account: accounts(:credit_card), amount: -500) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer transaction dates must be within 4 days of each other", transfer.errors.full_messages.first + end + + test "from_accounts converts amounts to the to_account's currency" do + accounts(:depository).update!(currency: "EUR") + + eur_account = accounts(:depository).reload + usd_account = accounts(:credit_card) + + ExchangeRate.create!( + from_currency: "EUR", + to_currency: "USD", + rate: 1.1, + date: Date.current, + ) + + transfer = Transfer.from_accounts( + from_account: eur_account, + to_account: usd_account, + date: Date.current, + amount: 500, + ) + + assert_equal 500, transfer.outflow_transaction.entry.amount + assert_equal "EUR", transfer.outflow_transaction.entry.currency + assert_equal -550, transfer.inflow_transaction.entry.amount + assert_equal "USD", transfer.inflow_transaction.entry.currency + + assert_difference -> { Transfer.count } => 1 do + transfer.save! + end + end +end diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 26db48c3..76ab1363 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase end def number_of_transactions_on_page - [ @user.family.entries.without_transfers.count, @page_size ].min + [ @user.family.entries.count, @page_size ].min end def all_transactions_checkbox diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index 601a50f8..a2481b18 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -19,71 +19,13 @@ class TransfersTest < ApplicationSystemTestCase select checking_name, from: "From" select savings_name, from: "To" - fill_in "account_transfer[amount]", with: 500 + fill_in "transfer[amount]", with: 500 fill_in "Date", with: transfer_date click_button "Create transfer" within "#entry-group-" + transfer_date.to_s do - assert_text "Transfer from" + assert_text "Payment to" end end - - test "can match 2 transactions and create a transfer" do - transfer_date = Date.current - outflow = accounts(:depository).entries.create! \ - name: "Outflow from checking account", - date: transfer_date, - amount: 100, - currency: "USD", - entryable: Account::Transaction.new - - inflow = accounts(:credit_card).entries.create! \ - name: "Inflow to cc account", - date: transfer_date, - amount: -100, - currency: "USD", - entryable: Account::Transaction.new - - visit transactions_url - - transaction_entry_checkbox(inflow).check - transaction_entry_checkbox(outflow).check - - bulk_transfer_action_button.click - - click_on "Mark as transfers" - - within "#entry-group-" + transfer_date.to_s do - assert_text "Outflow" - assert_text "Inflow" - end - end - - test "can mark a single transaction as a transfer" do - txn = @user.family.entries.account_transactions.reverse_chronological.first - - within "#" + dom_id(txn) do - assert_text txn.account_transaction.category.name || "Uncategorized" - end - - transaction_entry_checkbox(txn).check - - bulk_transfer_action_button.click - click_on "Mark as transfers" - - within "#" + dom_id(txn) do - assert_no_text "Uncategorized" - end - end - - private - - def transaction_entry_checkbox(transaction_entry) - find("#" + dom_id(transaction_entry, "selection")) - end - - def bulk_transfer_action_button - find("#bulk-transfer-btn") - end end From 2c30e18c9b28e7e123208c77f3fdd72662292bae Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 7 Jan 2025 11:31:44 -0500 Subject: [PATCH 100/626] Fix enrichment setting --- app/models/account/syncer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 27cd139d..7817a308 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -13,8 +13,8 @@ class Account::Syncer update_account_info(balances, holdings) unless account.plaid_account_id.present? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency - # Enrich if user opted in or if we're syncing transactions from a Plaid account - if account.family.data_enrichment_enabled? || account.plaid_account_id.present? + # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app + if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?) account.enrich_data_later else Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") From 997d0355d4d64357905d5c61ec5d477ed863ef74 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 7 Jan 2025 11:54:19 -0500 Subject: [PATCH 101/626] Use livereload from source --- Gemfile | 2 +- Gemfile.lock | 15 +++++---------- app/views/layouts/application.html.erb | 4 ++-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Gemfile b/Gemfile index e7141eaa..10ad4ba6 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ group :development, :test do end group :development do - gem "hotwire-livereload", github: "kirillplatonov/hotwire-livereload" + gem "hotwire-livereload" gem "letter_opener" gem "ruby-lsp-rails" gem "web-console" diff --git a/Gemfile.lock b/Gemfile.lock index 32fb9a8e..babac30c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,3 @@ -GIT - remote: https://github.com/kirillplatonov/hotwire-livereload.git - revision: 4ff247dd3e6b57abe439e240de676a4f48e875e7 - specs: - hotwire-livereload (2.0.0) - actioncable (>= 7.0.0) - listen (>= 3.0.0) - railties (>= 7.0.0) - GIT remote: https://github.com/maybe-finance/lucide-rails.git revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0 @@ -194,6 +185,10 @@ GEM thor (>= 1.0.0) hashdiff (1.1.1) highline (3.0.1) + hotwire-livereload (2.0.0) + actioncable (>= 7.0.0) + listen (>= 3.0.0) + railties (>= 7.0.0) hotwire_combobox (0.3.2) rails (>= 7.0.7.2) stimulus-rails (>= 1.2) @@ -500,7 +495,7 @@ DEPENDENCIES faraday-multipart faraday-retry good_job - hotwire-livereload! + hotwire-livereload hotwire_combobox i18n-tasks image_processing (>= 1.2) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 165c3740..ca2da86d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,8 +6,8 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": Rails.env.production? ? "reload" : "" %> - <%= stylesheet_link_tag "application", "data-turbo-track": Rails.env.production? ? "reload" : "" %> + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> From 71598d26cb20ee9f50a9db8dbfa8ca58c99f4a8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:55:02 -0500 Subject: [PATCH 102/626] Bump jwt from 2.9.3 to 2.10.1 (#1600) Bumps [jwt](https://github.com/jwt/ruby-jwt) from 2.9.3 to 2.10.1. - [Release notes](https://github.com/jwt/ruby-jwt/releases) - [Changelog](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md) - [Commits](https://github.com/jwt/ruby-jwt/compare/v2.9.3...v2.10.1) --- updated-dependencies: - dependency-name: jwt 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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index babac30c..2ca565fe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,7 +224,7 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.9.0) - jwt (2.9.3) + jwt (2.10.1) base64 language_server-protocol (3.17.0.3) launchy (3.0.1) From 1f4c2165eb211cddf24397c0e31ee61b604fc733 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:55:13 -0500 Subject: [PATCH 103/626] Bump aws-sdk-s3 from 1.176.1 to 1.177.0 (#1598) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.176.1 to 1.177.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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2ca565fe..2619adea 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.1023.0) - aws-sdk-core (3.214.0) + aws-partitions (1.1031.0) + aws-sdk-core (3.214.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -92,7 +92,7 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.176.1) + aws-sdk-s3 (1.177.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 871a68b5bc29a9cdf52eeaa8544df6eaca434bf4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:55:24 -0500 Subject: [PATCH 104/626] Bump tailwindcss-rails from 3.0.0 to 3.1.0 (#1597) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.0.0 to 3.1.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.0.0...v3.1.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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2619adea..7827d932 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -236,7 +236,7 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.4) - loofah (2.23.1) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -306,7 +306,8 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.8) - rack-session (2.0.0) + rack-session (2.1.0) + base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) @@ -431,15 +432,15 @@ GEM railties (>= 6.0.0) stringio (3.1.2) stripe (13.3.0) - tailwindcss-rails (3.0.0) + tailwindcss-rails (3.1.0) railties (>= 7.0.0) tailwindcss-ruby - tailwindcss-ruby (3.4.14) - tailwindcss-ruby (3.4.14-aarch64-linux) - tailwindcss-ruby (3.4.14-arm-linux) - tailwindcss-ruby (3.4.14-arm64-darwin) - tailwindcss-ruby (3.4.14-x86_64-darwin) - tailwindcss-ruby (3.4.14-x86_64-linux) + 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) + 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) thor (1.3.2) From b50b7b30e8b79850d113d852a24c4cde874d18a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:55:46 -0500 Subject: [PATCH 105/626] Bump good_job from 4.6.0 to 4.7.0 (#1596) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.6.0 to 4.7.0. - [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.6.0...v4.7.0) --- 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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7827d932..6a5078f8 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.6.0) + good_job (4.7.0) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From 5449fc49efdf9b8f62a86976f84b194179f95c95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:19:52 -0500 Subject: [PATCH 106/626] Bump tailwindcss-rails from 3.1.0 to 3.2.0 (#1618) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.1.0 to 3.2.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.1.0...v3.2.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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6a5078f8..f0e7d225 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -127,7 +127,7 @@ GEM childprocess (5.0.0) climate_control (1.2.0) concurrent-ruby (1.3.4) - connection_pool (2.4.1) + connection_pool (2.5.0) crack (1.0.0) bigdecimal rexml @@ -235,7 +235,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.4) + logger (1.6.5) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -432,7 +432,7 @@ GEM railties (>= 6.0.0) stringio (3.1.2) stripe (13.3.0) - tailwindcss-rails (3.1.0) + tailwindcss-rails (3.2.0) railties (>= 7.0.0) tailwindcss-ruby tailwindcss-ruby (3.4.17) From e4e5ae9f25828d9ca8882dd440996e58c33a4d25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:20:03 -0500 Subject: [PATCH 107/626] Bump ruby-lsp-rails from 0.3.27 to 0.3.29 (#1617) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.27 to 0.3.29. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.27...v0.3.29) --- 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 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f0e7d225..7322d6b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -291,7 +291,7 @@ GEM plaid (34.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) - prism (1.2.0) + prism (1.3.0) propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -353,7 +353,7 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.6.1) + rbs (3.8.1) logger rdoc (6.10.0) psych (>= 4.0.0) @@ -390,13 +390,13 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.22.1) + ruby-lsp (0.23.5) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.27) - ruby-lsp (>= 0.22.0, < 0.23.0) + ruby-lsp-rails (0.3.29) + ruby-lsp (>= 0.23.0, < 0.24.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) ffi (~> 1.12) @@ -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.11663) + sorbet-runtime (0.5.11751) stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) From 413ec6cbed72bcf22ea97dce6a434d4202c0facc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:20:13 -0500 Subject: [PATCH 108/626] Bump erb_lint from 0.7.0 to 0.8.0 (#1616) Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.7.0 to 0.8.0. - [Release notes](https://github.com/Shopify/erb-lint/releases) - [Commits](https://github.com/Shopify/erb-lint/compare/v0.7.0...v0.8.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 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7322d6b8..2880ab28 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.7.0) + erb_lint (0.8.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -223,7 +223,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.9.0) + json (2.9.1) jwt (2.10.1) base64 language_server-protocol (3.17.0.3) @@ -284,7 +284,7 @@ GEM sawyer (~> 0.9) pagy (9.3.3) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.6.0) ast (~> 2.4.1) racc pg (1.5.9) @@ -358,21 +358,21 @@ GEM rdoc (6.10.0) psych (>= 4.0.0) redcarpet (3.6.0) - regexp_parser (2.9.2) + regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) rexml (3.3.9) - rubocop (1.67.0) + rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-minitest (0.35.0) rubocop (>= 1.61, < 2.0) From 195ec85d96bd578098c8dc9ea084de4db28be03b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 16 Jan 2025 14:36:37 -0500 Subject: [PATCH 109/626] Budgeting V1 (#1609) * Budgeting V1 * Basic UI template * Fully scaffolded budgeting v1 * Basic working budget * Finalize donut chart for budgets * Allow categorization of loan payments for budget * Include loan payments in incomes_and_expenses scope * Add budget allocations progress * Empty states * Clean up budget methods * Category aggregation queries * Handle overage scenarios in form * Finalize budget donut chart controller * Passing tests * Fix allocation naming * Add income category migration * Native support for uncategorized budget category * Formatting * Fix subcategory sort order, padding * Fix calculation for category rollups in budget --- .../budget_categories_controller.rb | 35 ++++ app/controllers/budgets_controller.rb | 55 ++++++ app/controllers/categories_controller.rb | 12 +- app/controllers/transactions_controller.rb | 2 +- app/controllers/transfers_controller.rb | 9 +- app/helpers/application_helper.rb | 10 + app/helpers/categories_helper.rb | 18 +- .../controllers/budget_form_controller.js | 25 +++ .../controllers/donut_chart_controller.js | 168 ++++++++++++++++ app/models/account/data_enricher.rb | 7 - app/models/account/entry.rb | 35 ++-- app/models/budget.rb | 181 ++++++++++++++++++ app/models/budget_category.rb | 82 ++++++++ app/models/budgeting_stats.rb | 29 +++ app/models/category.rb | 68 +++++-- app/models/category_stats.rb | 179 +++++++++++++++++ app/models/demo/generator.rb | 32 ++-- app/models/family.rb | 55 ++++++ app/models/transfer.rb | 54 +++--- .../transactions/_transaction.html.erb | 2 +- .../_transaction_category.html.erb | 6 +- .../account/valuations/_valuation.html.erb | 2 +- .../_allocation_progress.erb | 26 +++ .../_allocation_progress_overage.html.erb | 25 +++ .../_budget_category.html.erb | 48 +++++ .../_budget_category_donut.html.erb | 22 +++ .../_budget_category_form.html.erb | 29 +++ .../budget_categories/_no_categories.html.erb | 17 ++ ...ncategorized_budget_category_form.html.erb | 21 ++ app/views/budget_categories/index.html.erb | 65 +++++++ app/views/budget_categories/show.html.erb | 150 +++++++++++++++ app/views/budgets/_actuals_summary.html.erb | 62 ++++++ app/views/budgets/_budget_categories.html.erb | 45 +++++ app/views/budgets/_budget_donut.html.erb | 61 ++++++ app/views/budgets/_budget_header.html.erb | 40 ++++ app/views/budgets/_budget_nav.html.erb | 37 ++++ app/views/budgets/_budgeted_summary.html.erb | 63 ++++++ .../budgets/_over_allocation_warning.html.erb | 13 ++ app/views/budgets/_picker.html.erb | 49 +++++ app/views/budgets/edit.html.erb | 47 +++++ app/views/budgets/show.html.erb | 67 +++++++ app/views/categories/_badge.html.erb | 5 +- .../categories/_category_list_group.html.erb | 25 +++ app/views/categories/_form.html.erb | 14 +- app/views/categories/index.html.erb | 28 +-- app/views/category/dropdowns/show.html.erb | 8 +- app/views/layouts/_sidebar.html.erb | 3 + app/views/layouts/wizard.html.erb | 23 +++ app/views/shared/_icon.html.erb | 6 + app/views/transfers/_transfer.html.erb | 12 +- app/views/transfers/show.html.erb | 6 +- config/locales/views/categories/en.yml | 4 +- config/locales/views/layout/en.yml | 1 + config/routes.rb | 8 +- db/migrate/20250108182147_create_budgets.rb | 15 ++ ...20250108200055_create_budget_categories.rb | 13 ++ .../20250110012347_category_classification.rb | 17 ++ db/schema.rb | 32 +++- .../account/transactions_controller_test.rb | 2 +- .../controllers/categories_controller_test.rb | 2 +- test/fixtures/budgets.yml | 7 + 61 files changed, 2044 insertions(+), 140 deletions(-) create mode 100644 app/controllers/budget_categories_controller.rb create mode 100644 app/controllers/budgets_controller.rb create mode 100644 app/javascript/controllers/budget_form_controller.js create mode 100644 app/javascript/controllers/donut_chart_controller.js create mode 100644 app/models/budget.rb create mode 100644 app/models/budget_category.rb create mode 100644 app/models/budgeting_stats.rb create mode 100644 app/models/category_stats.rb create mode 100644 app/views/budget_categories/_allocation_progress.erb create mode 100644 app/views/budget_categories/_allocation_progress_overage.html.erb create mode 100644 app/views/budget_categories/_budget_category.html.erb create mode 100644 app/views/budget_categories/_budget_category_donut.html.erb create mode 100644 app/views/budget_categories/_budget_category_form.html.erb create mode 100644 app/views/budget_categories/_no_categories.html.erb create mode 100644 app/views/budget_categories/_uncategorized_budget_category_form.html.erb create mode 100644 app/views/budget_categories/index.html.erb create mode 100644 app/views/budget_categories/show.html.erb create mode 100644 app/views/budgets/_actuals_summary.html.erb create mode 100644 app/views/budgets/_budget_categories.html.erb create mode 100644 app/views/budgets/_budget_donut.html.erb create mode 100644 app/views/budgets/_budget_header.html.erb create mode 100644 app/views/budgets/_budget_nav.html.erb create mode 100644 app/views/budgets/_budgeted_summary.html.erb create mode 100644 app/views/budgets/_over_allocation_warning.html.erb create mode 100644 app/views/budgets/_picker.html.erb create mode 100644 app/views/budgets/edit.html.erb create mode 100644 app/views/budgets/show.html.erb create mode 100644 app/views/categories/_category_list_group.html.erb create mode 100644 app/views/layouts/wizard.html.erb create mode 100644 app/views/shared/_icon.html.erb create mode 100644 db/migrate/20250108182147_create_budgets.rb create mode 100644 db/migrate/20250108200055_create_budget_categories.rb create mode 100644 db/migrate/20250110012347_category_classification.rb create mode 100644 test/fixtures/budgets.yml diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb new file mode 100644 index 00000000..fa6e22e4 --- /dev/null +++ b/app/controllers/budget_categories_controller.rb @@ -0,0 +1,35 @@ +class BudgetCategoriesController < ApplicationController + def index + @budget = Current.family.budgets.find(params[:budget_id]) + render layout: "wizard" + end + + def show + @budget = Current.family.budgets.find(params[:budget_id]) + + @recent_transactions = @budget.entries + + if params[:id] == BudgetCategory.uncategorized.id + @budget_category = @budget.uncategorized_budget_category + @recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil }) + else + @budget_category = Current.family.budget_categories.find(params[:id]) + @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id") + .where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id) + end + + @recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3) + end + + def update + @budget_category = Current.family.budget_categories.find(params[:id]) + @budget_category.update!(budget_category_params) + + redirect_to budget_budget_categories_path(@budget_category.budget) + end + + private + def budget_category_params + params.require(:budget_category).permit(:budgeted_spending) + end +end diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb new file mode 100644 index 00000000..4ea71169 --- /dev/null +++ b/app/controllers/budgets_controller.rb @@ -0,0 +1,55 @@ +class BudgetsController < ApplicationController + before_action :set_budget, only: %i[show edit update] + + def index + redirect_to_current_month_budget + end + + def show + @next_budget = @budget.next_budget + @previous_budget = @budget.previous_budget + @latest_budget = Budget.find_or_bootstrap(Current.family) + render layout: with_sidebar + end + + def edit + render layout: "wizard" + end + + def update + @budget.update!(budget_params) + redirect_to budget_budget_categories_path(@budget) + end + + def create + start_date = Date.parse(budget_create_params[:start_date]) + @budget = Budget.find_or_bootstrap(Current.family, date: start_date) + redirect_to budget_path(@budget) + end + + def picker + render partial: "budgets/picker", locals: { + family: Current.family, + year: params[:year].to_i || Date.current.year + } + end + + private + def budget_create_params + params.require(:budget).permit(:start_date) + end + + def budget_params + params.require(:budget).permit(:budgeted_spending, :expected_income) + end + + def set_budget + @budget = Current.family.budgets.find(params[:id]) + @budget.sync_budget_categories + end + + def redirect_to_current_month_budget + current_budget = Budget.find_or_bootstrap(Current.family) + redirect_to budget_path(current_budget) + end +end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index cbd468ea..03752869 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -19,9 +19,15 @@ class CategoriesController < ApplicationController if @category.save @transaction.update(category_id: @category.id) if @transaction - redirect_back_or_to categories_path, notice: t(".success") + flash[: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 - @categories = Current.family.categories.alphabetically.where(parent_id: nil) + @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) render :new, status: :unprocessable_entity end end @@ -60,6 +66,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color, :parent_id) + params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon) end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index f20a3304..80248ef2 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -3,7 +3,7 @@ class TransactionsController < ApplicationController def index @q = search_params - search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological + search_query = Current.family.transactions.search(@q).reverse_chronological @pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") totals_query = search_query.incomes_and_expenses diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 14c64422..a7d04bad 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -8,6 +8,7 @@ class TransfersController < ApplicationController end def show + @categories = Current.family.categories.expenses end def create @@ -37,7 +38,11 @@ class TransfersController < ApplicationController end def update - @transfer.update!(transfer_update_params) + Transfer.transaction do + @transfer.update!(transfer_update_params.except(:category_id)) + @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) + end + respond_to do |format| format.html { redirect_back_or_to transactions_url, notice: t(".success") } format.turbo_stream @@ -61,6 +66,6 @@ class TransfersController < ApplicationController end def transfer_update_params - params.require(:transfer).permit(:notes, :status) + params.require(:transfer).permit(:notes, :status, :category_id) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0bead33e..4f1c9499 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -15,6 +15,16 @@ module ApplicationHelper ] end + def icon(key, size: "md", color: "current") + render partial: "shared/icon", locals: { key:, size:, color: } + end + + # Convert alpha (0-1) to 8-digit hex (00-FF) + def hex_with_alpha(hex, alpha) + alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0") + "#{hex}#{alpha_hex}" + end + def title(page_title) content_for(:title) { page_title } end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 9250f1d7..2d586dee 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -1,20 +1,16 @@ module CategoriesHelper - def null_category - Category.new \ - name: "Uncategorized", - color: Category::UNCATEGORIZED_COLOR - end - def transfer_category Category.new \ - name: "⇄ Transfer", - color: Category::TRANSFER_COLOR + name: "Transfer", + color: Category::TRANSFER_COLOR, + lucide_icon: "arrow-right-left" end def payment_category Category.new \ - name: "→ Payment", - color: Category::PAYMENT_COLOR + name: "Payment", + color: Category::PAYMENT_COLOR, + lucide_icon: "arrow-right" end def trade_category @@ -24,6 +20,6 @@ module CategoriesHelper end def family_categories - [ null_category ].concat(Current.family.categories.alphabetically) + [ Category.uncategorized ].concat(Current.family.categories.alphabetically) end end diff --git a/app/javascript/controllers/budget_form_controller.js b/app/javascript/controllers/budget_form_controller.js new file mode 100644 index 00000000..0647736e --- /dev/null +++ b/app/javascript/controllers/budget_form_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="budget-form" +export default class extends Controller { + toggleAutoFill(e) { + const expectedIncome = e.params.income; + const budgetedSpending = e.params.spending; + + if (e.target.checked) { + this.#fillField(expectedIncome.key, expectedIncome.value); + this.#fillField(budgetedSpending.key, budgetedSpending.value); + } else { + this.#clearField(expectedIncome.key); + this.#clearField(budgetedSpending.key); + } + } + + #fillField(id, value) { + this.element.querySelector(`input[id="${id}"]`).value = value; + } + + #clearField(id) { + this.element.querySelector(`input[id="${id}"]`).value = ""; + } +} diff --git a/app/javascript/controllers/donut_chart_controller.js b/app/javascript/controllers/donut_chart_controller.js new file mode 100644 index 00000000..55c7cbb3 --- /dev/null +++ b/app/javascript/controllers/donut_chart_controller.js @@ -0,0 +1,168 @@ +import { Controller } from "@hotwired/stimulus"; +import * as d3 from "d3"; + +// Connects to data-controller="donut-chart" +export default class extends Controller { + static targets = ["chartContainer", "contentContainer", "defaultContent"]; + static values = { + segments: { type: Array, default: [] }, + unusedSegmentId: { type: String, default: "unused" }, + overageSegmentId: { type: String, default: "overage" }, + segmentHeight: { type: Number, default: 3 }, + segmentOpacity: { type: Number, default: 1 }, + }; + + #viewBoxSize = 100; + #minSegmentAngle = this.segmentHeightValue * 0.01; + + connect() { + this.#draw(); + document.addEventListener("turbo:load", this.#redraw); + this.element.addEventListener("mouseleave", this.#clearSegmentHover); + } + + disconnect() { + this.#teardown(); + document.removeEventListener("turbo:load", this.#redraw); + this.element.removeEventListener("mouseleave", this.#clearSegmentHover); + } + + get #data() { + const totalPieValue = this.segmentsValue.reduce( + (acc, s) => acc + Number(s.amount), + 0, + ); + + // Overage is always first segment, unused is always last segment + return this.segmentsValue + .filter((s) => s.amount > 0) + .map((s) => ({ + ...s, + amount: Math.max( + Number(s.amount), + totalPieValue * this.#minSegmentAngle, + ), + })) + .sort((a, b) => { + if (a.id === this.overageSegmentIdValue) return -1; + if (b.id === this.overageSegmentIdValue) return 1; + if (a.id === this.unusedSegmentIdValue) return 1; + if (b.id === this.unusedSegmentIdValue) return -1; + return b.amount - a.amount; + }); + } + + #redraw = () => { + this.#teardown(); + this.#draw(); + }; + + #teardown() { + d3.select(this.chartContainerTarget).selectAll("*").remove(); + } + + #draw() { + const svg = d3 + .select(this.chartContainerTarget) + .append("svg") + .attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio + .attr("preserveAspectRatio", "xMidYMid meet") + .attr("class", "w-full h-full"); + + const pie = d3 + .pie() + .sortValues(null) // Preserve order of segments + .value((d) => d.amount); + + const mainArc = d3 + .arc() + .innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue) + .outerRadius(this.#viewBoxSize / 2) + .cornerRadius(this.segmentHeightValue) + .padAngle(this.#minSegmentAngle); + + const segmentArcs = svg + .append("g") + .attr( + "transform", + `translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`, + ) + .selectAll("arc") + .data(pie(this.#data)) + .enter() + .append("g") + .attr("class", "arc pointer-events-auto") + .append("path") + .attr("data-segment-id", (d) => d.data.id) + .attr("data-original-color", this.#transformRingColor) + .attr("fill", this.#transformRingColor) + .attr("d", mainArc); + + // Ensures that user can click on default content without triggering hover on a segment if that is their intent + let hoverTimeout = null; + + segmentArcs + .on("mouseover", (event) => { + hoverTimeout = setTimeout(() => { + this.#clearSegmentHover(); + this.#handleSegmentHover(event); + }, 150); + }) + .on("mouseleave", () => { + clearTimeout(hoverTimeout); + }); + } + + #transformRingColor = ({ data: { id, color } }) => { + if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) { + return color; + } + + const reducedOpacityColor = d3.color(color); + reducedOpacityColor.opacity = this.segmentOpacityValue; + return reducedOpacityColor; + }; + + // Highlights segment and shows segment specific content (all other segments are grayed out) + #handleSegmentHover(event) { + const segmentId = event.target.dataset.segmentId; + const template = this.element.querySelector(`#segment_${segmentId}`); + const unusedSegmentId = this.unusedSegmentIdValue; + + if (!template) return; + + d3.select(this.chartContainerTarget) + .selectAll("path") + .attr("fill", function () { + if (this.dataset.segmentId === segmentId) { + if (this.dataset.segmentId === unusedSegmentId) { + return "#A3A3A3"; + } + + return this.dataset.originalColor; + } + + return "#F0F0F0"; + }); + + this.defaultContentTarget.classList.add("hidden"); + template.classList.remove("hidden"); + } + + // Restores original segment colors and hides segment specific content + #clearSegmentHover = () => { + this.defaultContentTarget.classList.remove("hidden"); + + d3.select(this.chartContainerTarget) + .selectAll("path") + .attr("fill", function () { + return this.dataset.originalColor; + }); + + for (const child of this.contentContainerTarget.children) { + if (child !== this.defaultContentTarget) { + child.classList.add("hidden"); + } + } + }; +} diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb index 0be57dc1..924d5894 100644 --- a/app/models/account/data_enricher.rb +++ b/app/models/account/data_enricher.rb @@ -18,7 +18,6 @@ class Account::DataEnricher Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}") merchants = {} - categories = {} candidates.each do |entry| if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil? @@ -37,17 +36,11 @@ class Account::DataEnricher end end - if info.category.present? - category = categories[info.category] ||= account.family.categories.find_or_create_by(name: info.category) - end - entryable_attributes = { id: entry.entryable_id } entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil? - entryable_attributes[:category_id] = category.id if category.present? && entry.entryable.category_id.nil? Account.transaction do merchant.save! if merchant.present? - category.save! if category.present? entry.update!( enriched_at: Time.current, enriched_name: info.name, diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b9fd5534..9cbfb32d 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -17,7 +17,7 @@ class Account::Entry < ApplicationRecord scope :chronological, -> { order( date: :asc, - Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, created_at: :asc ) } @@ -25,18 +25,27 @@ class Account::Entry < ApplicationRecord scope :reverse_chronological, -> { order( date: :desc, - Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, created_at: :desc ) } - # All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included) + # All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses scope :incomes_and_expenses, -> { - joins( - 'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id - LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id' - ) - .where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'") + 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')") + } + + scope :incomes, -> { + incomes_and_expenses.where("account_entries.amount <= 0") + } + + scope :expenses, -> { + incomes_and_expenses.where("account_entries.amount > 0") } scope :with_converted_amount, ->(currency) { @@ -137,18 +146,16 @@ class Account::Entry < ApplicationRecord all.size end - def income_total(currency = "USD") - total = account_transactions.includes(:entryable).incomes_and_expenses - .where("account_entries.amount <= 0") + 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 Money.new(total, currency) end - def expense_total(currency = "USD") - total = account_transactions.includes(:entryable).incomes_and_expenses - .where("account_entries.amount > 0") + 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 diff --git a/app/models/budget.rb b/app/models/budget.rb new file mode 100644 index 00000000..637ff50b --- /dev/null +++ b/app/models/budget.rb @@ -0,0 +1,181 @@ +class Budget < ApplicationRecord + include Monetizable + + belongs_to :family + + has_many :budget_categories, dependent: :destroy + + validates :start_date, :end_date, presence: true + validates :start_date, :end_date, uniqueness: { scope: :family_id } + + monetize :budgeted_spending, :expected_income, :allocated_spending, + :actual_spending, :available_to_spend, :available_to_allocate, + :estimated_spending, :estimated_income, :actual_income + + class << self + def for_date(date) + find_by(start_date: date.beginning_of_month, end_date: date.end_of_month) + end + + def find_or_bootstrap(family, date: Date.current) + Budget.transaction do + budget = Budget.find_or_create_by( + family: family, + start_date: date.beginning_of_month, + end_date: date.end_of_month, + currency: family.currency + ) + + budget.sync_budget_categories + + budget + end + end + end + + def sync_budget_categories + family.categories.expenses.each do |category| + budget_categories.find_or_create_by( + category: category, + ) do |bc| + bc.budgeted_spending = 0 + bc.currency = family.currency + end + end + end + + def uncategorized_budget_category + budget_categories.uncategorized.tap do |bc| + bc.budgeted_spending = [ available_to_allocate, 0 ].max + bc.currency = family.currency + end + end + + def entries + family.entries.incomes_and_expenses.where(date: start_date..end_date) + end + + def name + start_date.strftime("%B %Y") + end + + def initialized? + budgeted_spending.present? + end + + def income_categories_with_totals + family.income_categories_with_totals(date: start_date) + end + + def expense_categories_with_totals + family.expense_categories_with_totals(date: start_date) + end + + def current? + start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month + end + + def previous_budget + prev_month_end_date = end_date - 1.month + return nil if prev_month_end_date < family.oldest_entry_date + family.budgets.find_or_bootstrap(family, date: prev_month_end_date) + end + + def next_budget + return nil if current? + next_start_date = start_date + 1.month + family.budgets.find_or_bootstrap(family, date: next_start_date) + end + + def to_donut_segments_json + unused_segment_id = "unused" + + # Continuous gray segment for empty budgets + return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid? + + segments = budget_categories.map do |bc| + { color: bc.category.color, amount: bc.actual_spending, id: bc.id } + end + + if available_to_spend.positive? + segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id }) + end + + segments + end + + # ============================================================================= + # Actuals: How much user has spent on each budget category + # ============================================================================= + def estimated_spending + family.budgeting_stats.avg_monthly_expenses&.abs + end + + def actual_spending + budget_categories.reject(&:subcategory?).sum(&:actual_spending) + end + + def available_to_spend + (budgeted_spending || 0) - actual_spending + end + + def percent_of_budget_spent + return 0 unless budgeted_spending > 0 + + (actual_spending / budgeted_spending.to_f) * 100 + end + + def overage_percent + return 0 unless available_to_spend.negative? + + available_to_spend.abs / actual_spending.to_f * 100 + end + + # ============================================================================= + # Budget allocations: How much user has budgeted for all categories combined + # ============================================================================= + def allocated_spending + budget_categories.sum(:budgeted_spending) + end + + def allocated_percent + return 0 unless budgeted_spending > 0 + + (allocated_spending / budgeted_spending.to_f) * 100 + end + + def available_to_allocate + (budgeted_spending || 0) - allocated_spending + end + + def allocations_valid? + initialized? && available_to_allocate.positive? && allocated_spending > 0 + end + + # ============================================================================= + # Income: How much user earned relative to what they expected to earn + # ============================================================================= + def estimated_income + family.budgeting_stats.avg_monthly_income&.abs + end + + def actual_income + family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs + end + + def actual_income_percent + return 0 unless expected_income > 0 + + (actual_income / expected_income.to_f) * 100 + end + + def remaining_expected_income + expected_income - actual_income + end + + def surplus_percent + return 0 unless remaining_expected_income.negative? + + remaining_expected_income.abs / expected_income.to_f * 100 + end +end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb new file mode 100644 index 00000000..a57a3a97 --- /dev/null +++ b/app/models/budget_category.rb @@ -0,0 +1,82 @@ +class BudgetCategory < ApplicationRecord + include Monetizable + + belongs_to :budget + belongs_to :category + + validates :budget_id, uniqueness: { scope: :category_id } + + monetize :budgeted_spending, :actual_spending, :available_to_spend + + class Group + attr_reader :budget_category, :budget_subcategories + + delegate :category, to: :budget_category + delegate :name, :color, to: :category + + def self.for(budget_categories) + top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? } + top_level_categories.map do |top_level_category| + subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? } + new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name }) + end.sort_by { |group| group.category.name } + end + + def initialize(budget_category, budget_subcategories = []) + @budget_category = budget_category + @budget_subcategories = budget_subcategories + end + end + + class << self + def uncategorized + new( + id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"), + category: nil, + ) + end + end + + def initialized? + budget.initialized? + end + + def category + super || budget.family.categories.uncategorized + end + + def subcategory? + category.parent_id.present? + end + + def actual_spending + category.month_total(date: budget.start_date) + end + + def available_to_spend + (budgeted_spending || 0) - actual_spending + end + + def percent_of_budget_spent + return 0 unless budgeted_spending > 0 + + (actual_spending / budgeted_spending) * 100 + end + + def to_donut_segments_json + unused_segment_id = "unused" + overage_segment_id = "overage" + + return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0 + + segments = [ { color: category.color, amount: actual_spending, id: id } ] + + if available_to_spend.negative? + segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id }) + else + segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id }) + end + + segments + end +end diff --git a/app/models/budgeting_stats.rb b/app/models/budgeting_stats.rb new file mode 100644 index 00000000..43fbd80f --- /dev/null +++ b/app/models/budgeting_stats.rb @@ -0,0 +1,29 @@ +class BudgetingStats + attr_reader :family + + def initialize(family) + @family = family + end + + def avg_monthly_income + income_expense_totals_query(Account::Entry.incomes) + end + + def avg_monthly_expenses + income_expense_totals_query(Account::Entry.expenses) + end + + private + def income_expense_totals_query(type_scope) + monthly_totals = family.entries + .merge(type_scope) + .select("SUM(account_entries.amount) as total") + .group(Arel.sql("date_trunc('month', account_entries.date)")) + + result = Family.select("AVG(mt.total)") + .from(monthly_totals, :mt) + .pick("AVG(mt.total)") + + result + end +end diff --git a/app/models/category.rb b/app/models/category.rb index 8d0c24b6..90d2ce92 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -4,6 +4,7 @@ class Category < ApplicationRecord belongs_to :family + has_many :budget_categories, dependent: :destroy has_many :subcategories, class_name: "Category", foreign_key: :parent_id belongs_to :parent, class_name: "Category", optional: true @@ -11,8 +12,11 @@ class Category < ApplicationRecord validates :name, uniqueness: { scope: :family_id } validate :category_level_limit + validate :nested_category_matches_parent_classification scope :alphabetically, -> { order(:name) } + scope :incomes, -> { where(classification: "income") } + scope :expenses, -> { where(classification: "expense") } COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] @@ -39,35 +43,43 @@ class Category < ApplicationRecord end class << self + def icon_codes + %w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees] + end + def bootstrap_defaults - default_categories.each do |name, color| + default_categories.each do |name, color, icon| find_or_create_by!(name: name) do |category| category.color = color + category.classification = "income" if name == "Income" + category.lucide_icon = icon end end end + def uncategorized + new( + name: "Uncategorized", + color: UNCATEGORIZED_COLOR, + lucide_icon: "circle-dashed" + ) + end + private def default_categories [ - [ "Income", "#e99537" ], - [ "Loan Payments", "#6471eb" ], - [ "Bank Fees", "#db5a54" ], - [ "Entertainment", "#df4e92" ], - [ "Food & Drink", "#c44fe9" ], - [ "Groceries", "#eb5429" ], - [ "Dining Out", "#61c9ea" ], - [ "General Merchandise", "#805dee" ], - [ "Clothing & Accessories", "#6ad28a" ], - [ "Electronics", "#e99537" ], - [ "Healthcare", "#4da568" ], - [ "Insurance", "#6471eb" ], - [ "Utilities", "#db5a54" ], - [ "Transportation", "#df4e92" ], - [ "Gas & Fuel", "#c44fe9" ], - [ "Education", "#eb5429" ], - [ "Charitable Donations", "#61c9ea" ], - [ "Subscriptions", "#805dee" ] + [ "Income", "#e99537", "circle-dollar-sign" ], + [ "Housing", "#6471eb", "house" ], + [ "Entertainment", "#df4e92", "drama" ], + [ "Food & Drink", "#eb5429", "utensils" ], + [ "Shopping", "#e99537", "shopping-cart" ], + [ "Healthcare", "#4da568", "pill" ], + [ "Insurance", "#6471eb", "piggy-bank" ], + [ "Utilities", "#db5a54", "lightbulb" ], + [ "Transportation", "#df4e92", "bus" ], + [ "Education", "#eb5429", "book" ], + [ "Gifts & Donations", "#61c9ea", "hand-helping" ], + [ "Subscriptions", "#805dee", "credit-card" ] ] end end @@ -83,10 +95,28 @@ class Category < ApplicationRecord parent.present? end + def avg_monthly_total + family.category_stats.avg_monthly_total_for(self) + end + + def median_monthly_total + family.category_stats.median_monthly_total_for(self) + end + + def month_total(date: Date.current) + family.category_stats.month_total_for(self, date: date) + end + private def category_level_limit if subcategory? && parent.subcategory? errors.add(:parent, "can't have more than 2 levels of subcategories") end end + + def nested_category_matches_parent_classification + if subcategory? && parent.classification != classification + errors.add(:parent, "must have the same classification as its parent") + end + end end diff --git a/app/models/category_stats.rb b/app/models/category_stats.rb new file mode 100644 index 00000000..631b95ee --- /dev/null +++ b/app/models/category_stats.rb @@ -0,0 +1,179 @@ +class CategoryStats + attr_reader :family + + def initialize(family) + @family = family + end + + def avg_monthly_total_for(category) + statistics_data[category.id]&.avg || 0 + end + + def median_monthly_total_for(category) + statistics_data[category.id]&.median || 0 + end + + def month_total_for(category, date: Date.current) + monthly_totals = totals_data[category.id] + + category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year } + + category_total&.amount || 0 + end + + def month_category_totals(date: Date.current) + by_classification = Hash.new { |h, k| h[k] = {} } + + totals_data.each_with_object(by_classification) do |(category_id, totals), result| + totals.each do |t| + next unless t.month == date.month && t.year == date.year + result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? } + result[t.classification][category_id][:amount] += t.amount.abs + end + end + + # Calculate percentages for each group + category_totals = [] + + [ "income", "expense" ].each do |classification| + totals = by_classification[classification] + + # Only include non-subcategory amounts in the total for percentage calculations + total_amount = totals.sum do |_, data| + data[:subcategory] ? 0 : data[:amount] + end + + next if total_amount.zero? + + totals.each do |category_id, data| + percentage = (data[:amount].to_f / total_amount * 100).round(1) + + category_totals << CategoryTotal.new( + category_id: category_id, + amount: data[:amount], + percentage: percentage, + classification: classification, + currency: family.currency, + subcategory?: data[:subcategory] + ) + end + end + + # Calculate totals based on non-subcategory amounts only + total_income = category_totals + .select { |ct| ct.classification == "income" && !ct.subcategory? } + .sum(&:amount) + + total_expense = category_totals + .select { |ct| ct.classification == "expense" && !ct.subcategory? } + .sum(&:amount) + + CategoryTotals.new( + total_income: total_income, + total_expense: total_expense, + category_totals: category_totals + ) + end + + private + Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true) + Stats = Struct.new(:avg, :median, :currency, keyword_init: true) + CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true) + CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true) + + def statistics_data + @statistics_data ||= begin + stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash| + next if totals.empty? + + amounts = totals.map(&:amount) + hash[category_id] = Stats.new( + avg: (amounts.sum.to_f / amounts.size).round, + median: calculate_median(amounts), + currency: family.currency + ) + end + end + end + + def totals_data + @totals_data ||= begin + totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash| + hash[row.category_id] ||= [] + existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year } + + if existing_total + existing_total.amount += row.total.to_i + else + hash[row.category_id] << Totals.new( + month: row.date.month, + year: row.date.year, + amount: row.total.to_i, + classification: row.classification, + currency: family.currency, + subcategory?: row.parent_category_id.present? + ) + end + + # If category is a parent, its total includes its own transactions + sum(child category transactions) + if row.parent_category_id + hash[row.parent_category_id] ||= [] + + existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year } + + if existing_parent_total + existing_parent_total.amount += row.total.to_i + else + hash[row.parent_category_id] << Totals.new( + month: row.date.month, + year: row.date.year, + amount: row.total.to_i, + classification: row.classification, + currency: family.currency, + subcategory?: false + ) + end + end + end + + # Ensure we have a default empty array for nil category, which represents "Uncategorized" + totals[nil] ||= [] + totals + end + end + + def monthly_totals_query + income_expense_classification = Arel.sql(" + CASE WHEN categories.id IS NULL THEN + CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END + ELSE categories.classification + END + ") + + family.entries + .incomes_and_expenses + .select( + "categories.id as category_id", + "categories.parent_id as parent_category_id", + income_expense_classification, + "date_trunc('month', account_entries.date) as date", + "SUM(account_entries.amount) as total" + ) + .joins("LEFT JOIN categories ON categories.id = account_transactions.category_id") + .group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)")) + .order(Arel.sql("date_trunc('month', account_entries.date) DESC")) + end + + + def calculate_median(numbers) + return 0 if numbers.empty? + + sorted = numbers.sort + mid = sorted.size / 2 + if sorted.size.odd? + sorted[mid] + else + ((sorted[mid-1] + sorted[mid]) / 2.0).round + end + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 2f65b9da..c008e623 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -87,18 +87,12 @@ class Demo::Generator end def create_categories! - categories = [ "Income", "Food & Drink", "Entertainment", "Travel", - "Personal Care", "General Services", "Auto & Transport", - "Rent & Utilities", "Home Improvement", "Shopping" ] - - categories.each do |category| - family.categories.create!(name: category, color: COLORS.sample) - end + family.categories.bootstrap_defaults food = family.categories.find_by(name: "Food & Drink") - family.categories.create!(name: "Restaurants", parent: food) - family.categories.create!(name: "Groceries", parent: food) - family.categories.create!(name: "Alcohol & Bars", parent: food) + family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense") + family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense") + family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense") end def create_merchants! @@ -362,17 +356,17 @@ class Demo::Generator "McDonald's" => "Food & Drink", "Target" => "Shopping", "Costco" => "Food & Drink", - "Home Depot" => "Home Improvement", - "Shell" => "Auto & Transport", + "Home Depot" => "Housing", + "Shell" => "Transportation", "Whole Foods" => "Food & Drink", - "Walgreens" => "Personal Care", + "Walgreens" => "Healthcare", "Nike" => "Shopping", - "Uber" => "Auto & Transport", - "Netflix" => "Entertainment", - "Spotify" => "Entertainment", - "Delta Airlines" => "Travel", - "Airbnb" => "Travel", - "Sephora" => "Personal Care" + "Uber" => "Transportation", + "Netflix" => "Subscriptions", + "Spotify" => "Subscriptions", + "Delta Airlines" => "Transportation", + "Airbnb" => "Housing", + "Sephora" => "Shopping" } categories.find { |c| c.name == mapping[merchant.name] } diff --git a/app/models/family.rb b/app/models/family.rb index 601692ed..8649cea1 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -17,6 +17,8 @@ class Family < ApplicationRecord has_many :issues, through: :accounts has_many :holdings, through: :accounts has_many :plaid_items, dependent: :destroy + has_many :budgets, dependent: :destroy + has_many :budget_categories, through: :budgets validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } @@ -56,6 +58,22 @@ class Family < ApplicationRecord ).link_token end + def income_categories_with_totals(date: Date.current) + categories_with_stats(classification: "income", date: date) + end + + def expense_categories_with_totals(date: Date.current) + categories_with_stats(classification: "expense", date: date) + end + + def category_stats + CategoryStats.new(self) + end + + def budgeting_stats + BudgetingStats.new(self) + end + def snapshot(period = Period.all) query = accounts.active.joins(:balances) .where("account_balances.currency = ?", self.currency) @@ -172,4 +190,41 @@ class Family < ApplicationRecord def primary_user users.order(:created_at).first end + + def oldest_entry_date + entries.order(:date).first&.date || Date.current + end + + private + CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true) + CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true) + + def categories_with_stats(classification:, date: Date.current) + totals = category_stats.month_category_totals(date: date) + + classified_totals = totals.category_totals.select { |t| t.classification == classification } + + if classification == "income" + total = totals.total_income + categories_scope = categories.incomes + else + total = totals.total_expense + categories_scope = categories.expenses + end + + categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ] + + CategoriesWithTotals.new( + total_money: Money.new(total, currency), + category_totals: categories_with_uncategorized.map do |category| + ct = classified_totals.find { |ct| ct.category_id == category&.id } + + CategoryWithStats.new( + category: category, + amount_money: Money.new(ct&.amount || 0, currency), + percentage: ct&.percentage || 0 + ) + end + ) + end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 265c516b..3f86fe94 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -42,34 +42,34 @@ class Transfer < ApplicationRecord end def auto_match_for_account(account) - matches = account.entries.account_transactions.joins(" - JOIN account_entries ae2 ON - account_entries.amount = -ae2.amount AND - account_entries.currency = ae2.currency AND - account_entries.account_id <> ae2.account_id AND - ABS(account_entries.date - ae2.date) <= 4 - ").select( - "account_entries.id", - "account_entries.entryable_id AS e1_entryable_id", - "ae2.entryable_id AS e2_entryable_id", - "account_entries.amount AS e1_amount", - "ae2.amount AS e2_amount" - ) + matches = Account::Entry.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 AND + existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) OR + (existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id) OR + (existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) + ) + ") + .where(existing_transfers: { id: nil }) + .where("inflow_candidates.account_id = ? AND outflow_candidates.account_id = ?", account.id, account.id) + .pluck(:inflow_transaction_id, :outflow_transaction_id) Transfer.transaction do - matches.each do |match| - inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id - outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id - - # Skip all rejected, or already matched transfers - next if Transfer.exists?( - inflow_transaction_id: inflow, - outflow_transaction_id: outflow - ) - + matches.each do |inflow_transaction_id, outflow_transaction_id| Transfer.create!( - inflow_transaction_id: inflow, - outflow_transaction_id: outflow + inflow_transaction_id: inflow_transaction_id, + outflow_transaction_id: outflow_transaction_id, ) end end @@ -109,6 +109,10 @@ class Transfer < ApplicationRecord to_account.liability? end + def categorizable? + to_account.accountable_type == "Loan" + end + private def transfer_has_different_accounts return unless inflow_transaction.present? && outflow_transaction.present? diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 6b963050..03b01e1d 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -24,7 +24,7 @@ <% if entry.new_record? %> <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to entry.display_name, + <%= 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), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb index ab0b33c8..5489d310 100644 --- a/app/views/account/transactions/_transaction_category.html.erb +++ b/app/views/account/transactions/_transaction_category.html.erb @@ -1,9 +1,9 @@ <%# locals: (entry:) %>
"> - <% if entry.account_transaction.transfer? %> - <%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %> - <% else %> + <% 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 %> <% end %>
diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index fb74b750..a98d0ced 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -13,7 +13,7 @@
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> - <%= lucide_icon icon, class: "w-4 h-4" %> + <%= lucide_icon icon, class: "w-4 h-4 shrink-0" %> <% end %>
diff --git a/app/views/budget_categories/_allocation_progress.erb b/app/views/budget_categories/_allocation_progress.erb new file mode 100644 index 00000000..56077ee9 --- /dev/null +++ b/app/views/budget_categories/_allocation_progress.erb @@ -0,0 +1,26 @@ +<%# locals: (budget:) %> + +
+
+
">
+ +

+ <%= number_to_percentage(budget.allocated_percent, precision: 0) %> set +

+ +

+ <%= format_money(budget.allocated_spending_money) %> + / + <%= format_money(budget.budgeted_spending_money) %> +

+
+ +
+
+
+ +
+ <%= format_money(budget.available_to_allocate_money) %> + left to allocate +
+
diff --git a/app/views/budget_categories/_allocation_progress_overage.html.erb b/app/views/budget_categories/_allocation_progress_overage.html.erb new file mode 100644 index 00000000..eaa485ae --- /dev/null +++ b/app/views/budget_categories/_allocation_progress_overage.html.erb @@ -0,0 +1,25 @@ +<%# locals: (budget:) %> + +
+
+
+ +

> 100% set

+ +

+ <%= format_money(budget.allocated_spending_money) %> + / + <%= format_money(budget.budgeted_spending_money) %> +

+
+ +
+
+
+ +
+

+ Budget exceeded by <%= format_money(budget.available_to_allocate_money.abs) %> +

+
+
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb new file mode 100644 index 00000000..e6c0e4cb --- /dev/null +++ b/app/views/budget_categories/_budget_category.html.erb @@ -0,0 +1,48 @@ +<%# locals: (budget_category:) %> + +<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %> + <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-white", data: { turbo_frame: "drawer" } do %> + + <% if budget_category.initialized? %> +
+ <%= render "budget_categories/budget_category_donut", budget_category: budget_category %> +
+ <% else %> +
+ <% if budget_category.category.lucide_icon %> + <%= icon(budget_category.category.lucide_icon) %> + <% else %> + <%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %> + <% end %> +
+ <% end %> + +
+

<%= budget_category.category.name %>

+ + <% if budget_category.initialized? %> + <% if budget_category.available_to_spend.negative? %> +

<%= format_money(budget_category.available_to_spend_money.abs) %> over

+ <% elsif budget_category.available_to_spend.zero? %> +

"> + <%= format_money(budget_category.available_to_spend_money) %> left +

+ <% else %> +

<%= format_money(budget_category.available_to_spend_money) %> left

+ <% end %> + <% else %> +

+ <%= format_money(budget_category.category.avg_monthly_total) %> avg +

+ <% end %> +
+ +
+

<%= format_money(budget_category.actual_spending_money) %>

+ + <% if budget_category.initialized? %> +

from <%= format_money(budget_category.budgeted_spending_money) %>

+ <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/budget_categories/_budget_category_donut.html.erb b/app/views/budget_categories/_budget_category_donut.html.erb new file mode 100644 index 00000000..517135fe --- /dev/null +++ b/app/views/budget_categories/_budget_category_donut.html.erb @@ -0,0 +1,22 @@ +<%# locals: (budget_category:) %> + +<%= tag.div data: { + controller: "donut-chart", + donut_chart_segments_value: budget_category.to_donut_segments_json, + donut_chart_segment_height_value: 5, + donut_chart_segment_opacity_value: 0.2 +}, class: "relative h-full" do %> +
+ +
+
+ <% if budget_category.category.lucide_icon %> + <%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %> + <% else %> + + <%= budget_category.category.name.first.upcase %> + + <% end %> +
+
+<% end %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb new file mode 100644 index 00000000..9b7a1cad --- /dev/null +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -0,0 +1,29 @@ +<%# locals: (budget_category:) %> + +<% currency = Money::Currency.new(budget_category.budget.currency) %> + +
+
+ +
+

<%= budget_category.category.name %>

+ +

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

+
+ +
+ <%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur", turbo_frame: :_top } do |f| %> +
+
+ <%= currency.symbol %> + <%= f.number_field :budgeted_spending, + class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", + placeholder: "0", + step: currency.step, + min: 0, + data: { auto_submit_form_target: "auto" } %> +
+
+ <% end %> +
+
diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb new file mode 100644 index 00000000..aaa0a865 --- /dev/null +++ b/app/views/budget_categories/_no_categories.html.erb @@ -0,0 +1,17 @@ +
+
+

Oops!

+

+ You have not created or assigned any expense categories to your transactions yet. +

+ +
+ <%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %> + + <%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + New category + <% end %> +
+
+
diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb new file mode 100644 index 00000000..da2b6997 --- /dev/null +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -0,0 +1,21 @@ +<%# locals: (budget:) %> + +<% budget_category = budget.uncategorized_budget_category %> + +
+
+ +
+

<%= budget_category.category.name %>

+

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

+
+ +
+
+
+ $ + <%= text_field_tag :uncategorized, budget_category.budgeted_spending, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %> +
+
+
+
diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb new file mode 100644 index 00000000..5adee7c6 --- /dev/null +++ b/app/views/budget_categories/index.html.erb @@ -0,0 +1,65 @@ +<%= content_for :header_nav do %> + <%= render "budgets/budget_nav", budget: @budget %> +<% end %> + +<%= content_for :previous_path, edit_budget_path(@budget) %> +<%= content_for :cancel_path, budget_path(@budget) %> + +
+
+
+

Edit your category budgets

+

+ Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized. +

+
+ +
+ <% if @budget.family.categories.empty? %> +
+ <%= render "budget_categories/no_categories" %> +
+ <% else %> +
+ <% if @budget.available_to_allocate.negative? %> + <%= render "budget_categories/allocation_progress_overage", budget: @budget %> + <% else %> + <%= render "budget_categories/allocation_progress", budget: @budget %> + <% end %> + +
+ <% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %> +
+ <%= render "budget_categories/budget_category_form", budget_category: group.budget_category %> + +
+ <% group.budget_subcategories.each do |budget_subcategory| %> +
+
+ <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %> +
+ + <%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %> +
+ <% end %> +
+
+ <% end %> + + <%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %> +
+ + <% if @budget.allocations_valid? %> + <%= link_to "Confirm", + budget_path(@budget), + class: "block btn btn--primary w-full text-center" %> + <% else %> + + Confirm + + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb new file mode 100644 index 00000000..e9d4b759 --- /dev/null +++ b/app/views/budget_categories/show.html.erb @@ -0,0 +1,150 @@ +<%= drawer do %> +
+
+
+

Category

+

+ <%= @budget_category.category.name %> +

+ + <% if @budget_category.budget.initialized? %> +

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

+ <% end %> +
+ + <% if @budget_category.budget.initialized? %> +
+ <%= render "budget_categories/budget_category_donut", + budget_category: @budget_category %> +
+ <% end %> +
+ +
+ +

Overview

+ <%= lucide_icon "chevron-down", + class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+
+
+
+ <%= @budget_category.budget.start_date.strftime("%b %Y") %> spending +
+
+ <%= format_money @budget_category.actual_spending_money %> +
+
+ + <% if @budget_category.budget.initialized? %> +
+
Status
+ <% if @budget_category.available_to_spend.negative? %> +
+ <%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %> + <%= format_money @budget_category.available_to_spend_money.abs %> + overspent +
+ <% elsif @budget_category.available_to_spend.zero? %> +
+ <%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %> + <%= format_money @budget_category.available_to_spend_money %> + left +
+ <% else %> +
+ <%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %> + <%= format_money @budget_category.available_to_spend_money %> + left +
+ <% end %> +
+ +
+
Budgeted
+
+ <%= format_money @budget_category.budgeted_spending %> +
+
+ <% end %> + +
+
Monthly average spending
+
+ <%= format_money @budget_category.category.avg_monthly_total %> +
+
+ +
+
Monthly median spending
+
+ <%= format_money @budget_category.category.median_monthly_total %> +
+
+
+
+
+ +
+ +

Recent Transactions

+ <%= lucide_icon "chevron-down", + class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+
+ <% if @recent_transactions.any? %> +
    + <% @recent_transactions.each_with_index do |entry, index| %> +
  • +
    +
    + <% unless index == @recent_transactions.length - 1 %> +
    + <% end %> +
    + +
    +
    +

    + <%= entry.date.strftime("%b %d") %> +

    +

    <%= entry.name %>

    +
    +

    + <%= format_money entry.amount_money %> +

    +
    +
  • + <% end %> +
+ + <%= link_to "View all category transactions", + transactions_path(q: { + categories: [@budget_category.category.name], + start_date: @budget.start_date, + end_date: @budget.end_date + }), + data: { turbo_frame: :_top }, + class: "block text-center btn btn--outline w-full" %> + <% else %> +

+ No transactions found for this budget period. +

+ <% end %> +
+
+
+
+<% end %> diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb new file mode 100644 index 00000000..0cd5acf8 --- /dev/null +++ b/app/views/budgets/_actuals_summary.html.erb @@ -0,0 +1,62 @@ +<%# locals: (budget:) %> + +
+
+

Income

+ + <% income_totals = budget.income_categories_with_totals %> + <% income_categories = income_totals.category_totals.reject { |ct| ct.amount_money.zero? }.sort_by { |ct| ct.percentage }.reverse %> + + <%= format_money(income_totals.total_money) %> + + + <% if income_categories.any? %> +
+
+ <% income_categories.each do |item| %> +
+ <% end %> +
+ +
+ <% income_categories.each do |item| %> +
+
+ <%= item.category.name %> + <%= number_to_percentage(item.percentage, precision: 0) %> +
+ <% end %> +
+
+ <% end %> +
+ +
+

Expenses

+ + <% expense_totals = budget.expense_categories_with_totals %> + <% expense_categories = expense_totals.category_totals.reject { |ct| ct.amount_money.zero? || ct.category.subcategory? }.sort_by { |ct| ct.percentage }.reverse %> + + <%= format_money(expense_totals.total_money) %> + + <% if expense_categories.any? %> +
+
+ <% expense_categories.each do |item| %> +
+ <% end %> +
+ +
+ <% expense_categories.each do |item| %> +
+
+ <%= item.category.name %> + <%= number_to_percentage(item.percentage, precision: 0) %> +
+ <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb new file mode 100644 index 00000000..86cc3012 --- /dev/null +++ b/app/views/budgets/_budget_categories.html.erb @@ -0,0 +1,45 @@ +<%# locals: (budget:) %> + +
+
+

Categories

+ · +

<%= budget.budget_categories.count %>

+ +

Amount

+
+ +
+ <% if budget.family.categories.expenses.empty? %> +
+ <%= render "budget_categories/no_categories" %> +
+ <% else %> + <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %> + + <% category_groups.each_with_index do |group, index| %> +
+ <%= render "budget_categories/budget_category", budget_category: group.budget_category %> + +
+ <% group.budget_subcategories.each do |budget_subcategory| %> +
+
+ <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %> +
+ + <%= render "budget_categories/budget_category", budget_category: budget_subcategory %> +
+ <% end %> +
+
+ +
+
+
+ <% end %> + + <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %> + <% end %> +
+
diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb new file mode 100644 index 00000000..2dc2fd72 --- /dev/null +++ b/app/views/budgets/_budget_donut.html.erb @@ -0,0 +1,61 @@ +<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %> +
+ +
+
+ <% if budget.initialized? %> +
+ Spent +
+ +
"> + <%= format_money(budget.actual_spending) %> +
+ + <%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %> + + of <%= format_money(budget.budgeted_spending_money) %> + + <%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %> + <% end %> + <% else %> +
+ <%= format_money Money.new(0, budget.currency || budget.family.currency) %> +
+ <%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %> + <%= lucide_icon "plus", class: "w-4 h-4 text-white" %> + New budget + <% end %> + <% end %> +
+ + <% budget.budget_categories.each do |bc| %> + + <% end %> + + +
+<% end %> diff --git a/app/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb new file mode 100644 index 00000000..5dd041dc --- /dev/null +++ b/app/views/budgets/_budget_header.html.erb @@ -0,0 +1,40 @@ +<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %> + +
+
+ <% if @previous_budget %> + <%= link_to budget_path(@previous_budget) do %> + <%= lucide_icon "chevron-left" %> + <% end %> + <% else %> + <%= lucide_icon "chevron-left", class: "text-gray-400" %> + <% end %> + + <% if @next_budget %> + <%= link_to budget_path(@next_budget) do %> + <%= lucide_icon "chevron-right" %> + <% end %> + <% else %> + <%= lucide_icon "chevron-right", class: "text-gray-400" %> + <% end %> +
+ +
+ <%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %> + <%= @budget.name %> + <%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + + +
+ +
+ <% if @budget.current? %> + Today + <% else %> + <%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %> + <% end %> +
+
diff --git a/app/views/budgets/_budget_nav.html.erb b/app/views/budgets/_budget_nav.html.erb new file mode 100644 index 00000000..2d336f4a --- /dev/null +++ b/app/views/budgets/_budget_nav.html.erb @@ -0,0 +1,37 @@ +<%# locals: (budget:) %> + +<% steps = [ + { name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 }, + { name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 }, +] %> + +
    + <% steps.each_with_index do |step, idx| %> +
  • + <% is_current = request.path == step[:path] %> + + <% text_class = if is_current + "text-gray-900" + else + step[:is_complete] ? "text-green-600" : "text-gray-500" + end %> + <% step_class = if is_current + "bg-gray-900 text-white" + else + step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50" + end %> + + <%= link_to step[:path], class: "flex items-center gap-3" do %> +
    + + <%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %> + + + <%= step[:name] %> +
    + <% end %> + +
    +
  • + <% end %> +
diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb new file mode 100644 index 00000000..a77f40b3 --- /dev/null +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -0,0 +1,63 @@ +<%# locals: (budget:) %> + +
+
+

Expected income

+ + + <%= format_money(budget.expected_income_money) %> + + +
+
+ <% if budget.remaining_expected_income.negative? %> +
+
+ <% else %> +
+
+ <% end %> +
+
+

<%= format_money(budget.actual_income_money) %> earned

+

+ <% if budget.remaining_expected_income.negative? %> + <%= format_money(budget.remaining_expected_income.abs) %> over + <% else %> + <%= format_money(budget.remaining_expected_income) %> left + <% end %> +

+
+
+
+ +
+

Budgeted

+ + + <%= format_money(budget.budgeted_spending_money) %> + + +
+
+ <% if budget.available_to_spend.negative? %> +
+
+ <% else %> +
+
+ <% end %> +
+
+

<%= format_money(budget.actual_spending_money) %> spent

+

+ <% if budget.available_to_spend.negative? %> + <%= format_money(budget.available_to_spend_money.abs) %> over + <% else %> + <%= format_money(budget.available_to_spend_money) %> left + <% end %> +

+
+
+
+
diff --git a/app/views/budgets/_over_allocation_warning.html.erb b/app/views/budgets/_over_allocation_warning.html.erb new file mode 100644 index 00000000..9999038c --- /dev/null +++ b/app/views/budgets/_over_allocation_warning.html.erb @@ -0,0 +1,13 @@ +<%# locals: (budget:) %> + +
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %> +

You have over-allocated your budget. Please fix your allocations.

+ + <%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %> + + Fix allocations + + <%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %> + <% end %> +
diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb new file mode 100644 index 00000000..53ad6c76 --- /dev/null +++ b/app/views/budgets/_picker.html.erb @@ -0,0 +1,49 @@ +<%# locals: (family:, year:) %> + +<%= turbo_frame_tag "budget_picker" do %> +
+
+ <% if year > family.oldest_entry_date.year %> + <%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %> + <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + <% else %> + + <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %> + + <% end %> + + + <%= year %> + + + <% if year < Date.current.year %> + <%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %> + <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + <% else %> + + <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %> + + <% end %> +
+ +
+ <% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, index| %> + <% month_number = index + 1 %> + <% start_date = Date.new(year, month_number) %> + <% budget = family.budgets.for_date(start_date) %> + + <% if budget %> + <%= link_to month_name, budget_path(budget), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-gray-900 hover:bg-gray-100 rounded-md" %> + <% elsif start_date >= family.oldest_entry_date.beginning_of_month && start_date <= Date.current %> + <%= button_to budgets_path(budget: { start_date: start_date }), data: { turbo_frame: "_top" }, class: "block w-full px-3 py-2 text-gray-900 hover:bg-gray-100 rounded-md" do %> + <%= month_name %> + <% end %> + <% else %> + <%= month_name %> + <% end %> + <% end %> +
+
+<% end %> diff --git a/app/views/budgets/edit.html.erb b/app/views/budgets/edit.html.erb new file mode 100644 index 00000000..c43acc5b --- /dev/null +++ b/app/views/budgets/edit.html.erb @@ -0,0 +1,47 @@ +<%= content_for :header_nav do %> + <%= render "budgets/budget_nav", budget: @budget %> +<% end %> + +<%= content_for :previous_path, budget_path(@budget) %> +<%= content_for :cancel_path, budget_path(@budget) %> + +
+
+
+

Setup your budget

+

+ Enter your monthly earnings and planned spending below to setup your budget. +

+
+ +
+ <%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %> + <%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %> + <%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %> + + <% if @budget.estimated_income && @budget.estimated_spending %> +
+ <%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %> +
+

Autosuggest income & spending budget

+

+ This will be based on transaction history. AI can make mistakes, verify before continuing. +

+
+ +
+ <%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: { + action: "change->budget-form#toggleAutoFill", + budget_form_income_param: { key: "budget_expected_income", value: @budget.estimated_income }, + budget_form_spending_param: { key: "budget_budgeted_spending", value: @budget.estimated_spending } + } %> + +
+
+ <% end %> + + <%= f.submit "Continue", class: "btn btn--primary w-full" %> + <% end %> +
+
+
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb new file mode 100644 index 00000000..f9734976 --- /dev/null +++ b/app/views/budgets/show.html.erb @@ -0,0 +1,67 @@ +
+ <%= render "budgets/budget_header", + budget: @budget, + previous_budget: @previous_budget, + next_budget: @next_budget, + latest_budget: @latest_budget %> + +
+
+
+ <% if @budget.available_to_allocate.negative? %> + <%= render "budgets/over_allocation_warning", budget: @budget %> + <% else %> + <%= render "budgets/budget_donut", budget: @budget %> + <% end %> +
+ +
+ <% if @budget.initialized? && @budget.available_to_allocate.positive? %> +
+ <% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %> + <% selected_tab = params[:tab].presence || "budgeted" %> + + <%= link_to "Budgeted", + budget_path(@budget, tab: "budgeted"), + class: class_names( + base_classes, + "bg-white shadow-xs text-gray-900": selected_tab == "budgeted", + "text-gray-500": selected_tab != "budgeted" + ) %> + + <%= link_to "Actual", + budget_path(@budget, tab: "actuals"), + class: class_names( + base_classes, + "bg-white shadow-xs text-gray-900": selected_tab == "actuals", + "text-gray-500": selected_tab != "actuals" + ) %> +
+ +
+ <%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %> +
+ <% else %> +
+ <%= render "budgets/actuals_summary", budget: @budget %> +
+ <% end %> +
+
+ +
+
+

Categories

+ + <%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %> + <%= icon "settings-2", color: "gray" %> + Edit + <% end %> +
+ +
+ <%= render "budgets/budget_categories", budget: @budget %> +
+
+
+
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index b6c6480c..67a42251 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -1,5 +1,5 @@ <%# locals: (category:) %> -<% category ||= null_category %> +<% category ||= Category.uncategorized %>
5%, white); border-color: color-mix(in srgb, <%= category.color %> 30%, white); color: <%= category.color %>;"> + <% if category.lucide_icon.present? %> + <%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %> + <% end %> <%= category.name %> diff --git a/app/views/categories/_category_list_group.html.erb b/app/views/categories/_category_list_group.html.erb new file mode 100644 index 00000000..671e10dd --- /dev/null +++ b/app/views/categories/_category_list_group.html.erb @@ -0,0 +1,25 @@ +<%# locals: (title:, categories:) %> + +
+
+

<%= title %>

+ · +

<%= categories.count %>

+
+ +
+
+ <% Category::Group.for(categories).each_with_index do |group, idx| %> + <%= render group.category %> + + <% group.subcategories.each do |subcategory| %> + <%= render subcategory %> + <% end %> + + <% unless idx == Category::Group.for(categories).count - 1 %> + <%= render "categories/ruler" %> + <% end %> + <% end %> +
+
+
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 2bca2191..d0cda832 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 } %> @@ -20,7 +20,19 @@ <%= render "shared/form_errors", model: category %> <% end %> +
+ <% Category.icon_codes.each do |icon| %> + + <% end %> +
+
+ <%= 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)" } %>
diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index c0cf8440..64e6acb9 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -14,28 +14,14 @@
<% if @categories.any? %> -
-
-

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

- · -

<%= @categories.count %>

-
+
+ <% if @categories.incomes.any? %> + <%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %> + <% end %> -
-
- <% Category::Group.for(@categories).each_with_index do |group, idx| %> - <%= render group.category %> - - <% group.subcategories.each do |subcategory| %> - <%= render subcategory %> - <% end %> - - <% unless idx == Category::Group.for(@categories).count - 1 %> - <%= render "categories/ruler" %> - <% end %> - <% end %> -
-
+ <% if @categories.expenses.any? %> + <%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %> + <% end %>
<% else %>
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 6df124ee..4a0047cb 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -41,12 +41,14 @@ <% end %> <% end %> - <%= link_to new_account_transaction_transfer_match_path(@transaction.entry), + <% unless @transaction.transfer? %> + <%= link_to new_account_transaction_transfer_match_path(@transaction.entry), class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100", data: { turbo_frame: "modal" } do %> - <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> + <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> -

Match transfer/payment

+

Match transfer/payment

+ <% end %> <% end %>
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index ff168f2d..42ac543d 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -81,6 +81,9 @@
  • <%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
  • +
  • + <%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %> +
  • diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb new file mode 100644 index 00000000..fc540a71 --- /dev/null +++ b/app/views/layouts/wizard.html.erb @@ -0,0 +1,23 @@ +<%= content_for :content do %> +
    +
    + <%= link_to content_for(:previous_path) || root_path do %> + <%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %> + <% end %> + + + + <%= link_to content_for(:cancel_path) || root_path do %> + <%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %> + <% end %> +
    + +
    + <%= yield %> +
    +
    +<% end %> + +<%= render template: "layouts/application" %> diff --git a/app/views/shared/_icon.html.erb b/app/views/shared/_icon.html.erb new file mode 100644 index 00000000..0e475933 --- /dev/null +++ b/app/views/shared/_icon.html.erb @@ -0,0 +1,6 @@ +<%# locals: (key:, size: "md", color: "current") %> + +<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %> +<% color_class = case color when "current" then "text-current" when "gray" then "text-gray-500" end %> + +<%= lucide_icon key, class: class_names(size_class, color_class, "shrink-0") %> diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb index 05b8db3b..ad0527db 100644 --- a/app/views/transfers/_transfer.html.erb +++ b/app/views/transfers/_transfer.html.erb @@ -52,7 +52,11 @@
    <%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %> - <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <% if transfer.payment? %> + <%= lucide_icon "arrow-right", class: "w-4 h-4" %> + <% else %> + <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <% end %> <%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
    @@ -63,7 +67,11 @@
    - <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> + <% if transfer.categorizable? %> + <%= render "account/transactions/transaction_category", entry: transfer.outflow_transaction.entry %> + <% else %> + <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> + <% end %>
    diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index f8307997..393c8ccf 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -70,7 +70,11 @@ <%= disclosure t(".details") do %> <%= styled_form_with model: @transfer, - data: { controller: "auto-submit-form" } do |f| %> + data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %> + <% if @transfer.categorizable? %> + <%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: "Category", include_blank: "Uncategorized", selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %> + <% end %> + <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 1d6aeb6e..9ff612e9 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -15,8 +15,10 @@ en: form: placeholder: Category name index: - bootstrap: Use default categories categories: Categories + bootstrap: Use default categories + categories_incomes: Income categories + categories_expenses: Expense 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 8adc4792..9054ee29 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -19,3 +19,4 @@ en: new_account: New account portfolio: Portfolio transactions: Transactions + budgeting: Budgeting diff --git a/config/routes.rb b/config/routes.rb index 440756ba..d49ca187 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,12 +38,18 @@ Rails.application.routes.draw do resource :dropdown, only: :show end - resources :categories do + resources :categories, except: :show do resources :deletions, only: %i[new create], module: :category post :bootstrap, on: :collection end + resources :budgets, only: %i[index show edit update create] do + get :picker, on: :collection + + resources :budget_categories, only: %i[index show update] + end + resources :merchants, only: %i[index new create edit update destroy] resources :transfers, only: %i[new create destroy show update] diff --git a/db/migrate/20250108182147_create_budgets.rb b/db/migrate/20250108182147_create_budgets.rb new file mode 100644 index 00000000..72a0f046 --- /dev/null +++ b/db/migrate/20250108182147_create_budgets.rb @@ -0,0 +1,15 @@ +class CreateBudgets < ActiveRecord::Migration[7.2] + def change + create_table :budgets, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.date :start_date, null: false + t.date :end_date, null: false + t.decimal :budgeted_spending, precision: 19, scale: 4 + t.decimal :expected_income, precision: 19, scale: 4 + t.string :currency, null: false + t.timestamps + end + + add_index :budgets, %i[family_id start_date end_date], unique: true + end +end diff --git a/db/migrate/20250108200055_create_budget_categories.rb b/db/migrate/20250108200055_create_budget_categories.rb new file mode 100644 index 00000000..d248884f --- /dev/null +++ b/db/migrate/20250108200055_create_budget_categories.rb @@ -0,0 +1,13 @@ +class CreateBudgetCategories < ActiveRecord::Migration[7.2] + def change + create_table :budget_categories, id: :uuid do |t| + t.references :budget, null: false, foreign_key: true, type: :uuid + t.references :category, null: false, foreign_key: true, type: :uuid + t.decimal :budgeted_spending, null: false, precision: 19, scale: 4 + t.string :currency, null: false + t.timestamps + end + + add_index :budget_categories, %i[budget_id category_id], unique: true + end +end diff --git a/db/migrate/20250110012347_category_classification.rb b/db/migrate/20250110012347_category_classification.rb new file mode 100644 index 00000000..a204bc42 --- /dev/null +++ b/db/migrate/20250110012347_category_classification.rb @@ -0,0 +1,17 @@ +class CategoryClassification < ActiveRecord::Migration[7.2] + def change + add_column :categories, :classification, :string, null: false, default: "expense" + add_column :categories, :lucide_icon, :string + + # Attempt to update existing user categories that are likely to be income + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE categories + SET classification = 'income' + WHERE lower(name) in ('income', 'incomes', 'other income', 'other incomes'); + SQL + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7c333cc3..dffd8081 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: 2024_12_31_140709) do +ActiveRecord::Schema[7.2].define(version: 2025_01_10_012347) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -160,6 +160,31 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end + create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "budget_id", null: false + t.uuid "category_id", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true + t.index ["budget_id"], name: "index_budget_categories_on_budget_id" + t.index ["category_id"], name: "index_budget_categories_on_category_id" + end + + create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.date "start_date", null: false + t.date "end_date", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.decimal "expected_income", precision: 19, scale: 4 + t.string "currency", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true + t.index ["family_id"], name: "index_budgets_on_family_id" + end + create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "color", default: "#6172F3", null: false @@ -167,6 +192,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" + t.string "classification", default: "expense", null: false + t.string "lucide_icon" t.index ["family_id"], name: "index_categories_on_family_id" end @@ -650,6 +677,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do add_foreign_key "accounts", "plaid_accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "budget_categories", "budgets" + add_foreign_key "budget_categories", "categories" + add_foreign_key "budgets", "families" add_foreign_key "categories", "families" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index 1b077eb2..0a754834 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest end test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions.incomes_and_expenses + transactions = @user.family.entries.incomes_and_expenses delete_count = transactions.size assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 22105ba2..07f45b6a 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -84,7 +84,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest end test "bootstrap" do - assert_difference "Category.count", 16 do + assert_difference "Category.count", 10 do post bootstrap_categories_url end diff --git a/test/fixtures/budgets.yml b/test/fixtures/budgets.yml new file mode 100644 index 00000000..e95ef917 --- /dev/null +++ b/test/fixtures/budgets.yml @@ -0,0 +1,7 @@ +one: + family: dylan_family + start_date: <%= Date.current.beginning_of_month %> + end_date: <%= Date.current.end_of_month %> + budgeted_spending: 5000 + expected_income: 7000 + currency: USD From e1d3c7a4a1f7a7710c690f88b7e7d5ed96959c40 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 16 Jan 2025 16:02:06 -0500 Subject: [PATCH 110/626] Add CA country code to Plaid link Signed-off-by: Zach Gollwitzer --- app/models/provider/plaid.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index e41a0a46..7b95cb88 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -74,7 +74,7 @@ class Provider::Plaid client_name: "Maybe Finance", products: [ get_primary_product(accountable_type) ], additional_consented_products: get_additional_consented_products(accountable_type), - country_codes: [ "US" ], + country_codes: [ "US", "CA" ], language: "en", webhook: webhooks_url, redirect_uri: redirect_url, From 60f1a1e2d2c2a3caca30190c79e83f514840d0dd Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 16 Jan 2025 16:24:14 -0500 Subject: [PATCH 111/626] Fix budget edit button --- app/models/budget.rb | 2 +- app/views/budgets/show.html.erb | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index 637ff50b..c5af34f7 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -139,7 +139,7 @@ class Budget < ApplicationRecord end def allocated_percent - return 0 unless budgeted_spending > 0 + return 0 unless budgeted_spending && budgeted_spending > 0 (allocated_spending / budgeted_spending.to_f) * 100 end diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index f9734976..0f63602b 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -53,9 +53,11 @@

    Categories

    - <%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %> - <%= icon "settings-2", color: "gray" %> - Edit + <% if @budget.initialized? %> + <%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %> + <%= icon "settings-2", color: "gray" %> + Edit + <% end %> <% end %>
    From 1ae4b4d61251dd14bf8a2bbf1e60822917c4d3e0 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 16 Jan 2025 17:56:42 -0500 Subject: [PATCH 112/626] Fix transfer matching logic (#1625) * Fix transfer matching logic * Fix tests --- app/models/transfer.rb | 35 +++++++++++++------ .../_transaction_category.html.erb | 2 +- config/locales/models/transfer/en.yml | 3 ++ test/models/transfer_test.rb | 32 +++++++++++++++-- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 3f86fe94..b0266389 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -7,6 +7,8 @@ class Transfer < ApplicationRecord 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,7 +44,10 @@ class Transfer < ApplicationRecord end def auto_match_for_account(account) - matches = Account::Entry.from("account_entries inflow_candidates") + 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 @@ -55,21 +60,21 @@ class Transfer < ApplicationRecord ) ").joins(" LEFT JOIN transfers existing_transfers ON ( - (existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND - existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) OR - (existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id) OR - (existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) + 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 }) - .where("inflow_candidates.account_id = ? AND outflow_candidates.account_id = ?", account.id, account.id) - .pluck(:inflow_transaction_id, :outflow_transaction_id) Transfer.transaction do - matches.each do |inflow_transaction_id, outflow_transaction_id| + matches.each do |match| Transfer.create!( - inflow_transaction_id: inflow_transaction_id, - outflow_transaction_id: outflow_transaction_id, + inflow_transaction_id: match.inflow_transaction_id, + outflow_transaction_id: match.outflow_transaction_id, ) end end @@ -114,11 +119,21 @@ 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 end + def transfer_has_same_family + return unless inflow_transaction.present? && outflow_transaction.present? + errors.add(:base, :must_be_from_same_family) unless inflow_transaction.entry.account.family == outflow_transaction.entry.account.family + end + def transfer_has_opposite_amounts return unless inflow_transaction.present? && outflow_transaction.present? diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb index 5489d310..1f204028 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? %> + <% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? || entry.account_transaction.transfer&.rejected? %> <%= render "categories/menu", transaction: entry.account_transaction %> <% else %> <%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %> diff --git a/config/locales/models/transfer/en.yml b/config/locales/models/transfer/en.yml index f373cc8e..6aa640be 100644 --- a/config/locales/models/transfer/en.yml +++ b/config/locales/models/transfer/en.yml @@ -12,6 +12,9 @@ en: 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_must_be_on_or_after_outflow: Inflow transaction must be on or after outflow transaction transfer: name: Transfer to %{to_account} payment_name: Payment to %{to_account} + diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb index a4460fd2..ea08c758 100644 --- a/test/models/transfer_test.rb +++ b/test/models/transfer_test.rb @@ -8,9 +8,18 @@ class TransferTest < ActiveSupport::TestCase @inflow = account_transactions(:transfer_in) 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: Date.current, account: accounts(:depository), amount: 500) - inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500) + 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.create!( @@ -68,6 +77,25 @@ class TransferTest < ActiveSupport::TestCase assert_equal "Transfer transaction dates must be within 4 days of each other", transfer.errors.full_messages.first end + test "transfer must be from the same family" do + family1 = families(:empty) + family2 = families(:dylan_family) + + family1_account = family1.accounts.create!(name: "Family 1 Account", balance: 5000, currency: "USD", accountable: Depository.new) + family2_account = family2.accounts.create!(name: "Family 2 Account", balance: 5000, currency: "USD", accountable: Depository.new) + + outflow_txn = create_transaction(date: Date.current, account: family1_account, amount: 500) + inflow_txn = create_transaction(date: Date.current, account: family2_account, amount: -500) + + transfer = Transfer.new( + inflow_transaction: inflow_txn.account_transaction, + outflow_transaction: outflow_txn.account_transaction, + ) + + assert transfer.invalid? + assert_equal "Transfer must be from the same family", transfer.errors.full_messages.first + end + test "from_accounts converts amounts to the to_account's currency" do accounts(:depository).update!(currency: "EUR") From ca8bdb624164430e239973fe406ade0b80fd9765 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 16 Jan 2025 19:05:34 -0500 Subject: [PATCH 113/626] Fix budget money formatting (#1626) --- app/models/budget.rb | 11 ++++++----- .../budget_categories/_budget_category_form.html.erb | 2 +- .../_uncategorized_budget_category_form.html.erb | 4 ++-- app/views/budgets/_budget_donut.html.erb | 2 +- app/views/budgets/_budgeted_summary.html.erb | 4 ++-- app/views/settings/preferences/show.html.erb | 3 +-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index c5af34f7..2abfe2cf 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -10,7 +10,7 @@ class Budget < ApplicationRecord monetize :budgeted_spending, :expected_income, :allocated_spending, :actual_spending, :available_to_spend, :available_to_allocate, - :estimated_spending, :estimated_income, :actual_income + :estimated_spending, :estimated_income, :actual_income, :remaining_expected_income class << self def for_date(date) @@ -19,12 +19,13 @@ class Budget < ApplicationRecord def find_or_bootstrap(family, date: Date.current) Budget.transaction do - budget = Budget.find_or_create_by( + budget = Budget.find_or_create_by!( family: family, start_date: date.beginning_of_month, - end_date: date.end_of_month, - currency: family.currency - ) + end_date: date.end_of_month + ) do |b| + b.currency = family.currency + end budget.sync_budget_categories diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index 9b7a1cad..efd98dc9 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(budget_category.category.avg_monthly_total, precision: 0) %>/m average

    +

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

    diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb index da2b6997..4e586379 100644 --- a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -7,14 +7,14 @@

    <%= budget_category.category.name %>

    -

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

    +

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

    $ - <%= text_field_tag :uncategorized, budget_category.budgeted_spending, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %> + <%= text_field_tag :uncategorized, budget_category.budgeted_spending_money, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %>
    diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 2dc2fd72..035389f7 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -9,7 +9,7 @@
    "> - <%= format_money(budget.actual_spending) %> + <%= format_money(budget.actual_spending_money) %>
    <%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %> diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb index a77f40b3..742d950c 100644 --- a/app/views/budgets/_budgeted_summary.html.erb +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -22,9 +22,9 @@

    <%= format_money(budget.actual_income_money) %> earned

    <% if budget.remaining_expected_income.negative? %> - <%= format_money(budget.remaining_expected_income.abs) %> over + <%= format_money(budget.remaining_expected_income_money.abs) %> over <% else %> - <%= format_money(budget.remaining_expected_income) %> left + <%= format_money(budget.remaining_expected_income_money) %> left <% end %>

    diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 8b066503..7af910e2 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -12,8 +12,7 @@ <%= form.fields_for :family do |family_form| %> <%= family_form.select :currency, currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, - { label: t(".currency") }, - { data: { auto_submit_form_target: "auto" } } %> + { label: t(".currency") }, disabled: true %> <%= family_form.select :locale, language_options, From aac9e5eca2ec3ceb48140117a2a6cfbbc5e0313e Mon Sep 17 00:00:00 2001 From: Jasper Delahaije <47220315+Repsay@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:48:16 +0100 Subject: [PATCH 114/626] Update family.rb (#1629) Add where statement to account_transactions overview to only give transactions and not valuations Signed-off-by: Jasper Delahaije <47220315+Repsay@users.noreply.github.com> --- app/models/family.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/family.rb b/app/models/family.rb index 8649cea1..fef014fe 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -110,6 +110,7 @@ class Family < ApplicationRecord ) .where("account_entries.date >= ?", period.date_range.begin) .where("account_entries.date <= ?", period.date_range.end) + .where("account_entries.entryable_type = 'Account::Transaction'") .where("transfers.id IS NULL") .group("accounts.id") .having("SUM(ABS(account_entries.amount)) > 0") From ae9287ec9bb1fde3e2714f253201e21334629d8b Mon Sep 17 00:00:00 2001 From: tlink <30464573+MrTob@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:21:00 +0100 Subject: [PATCH 115/626] FIX: correct display of percentages (#1622) * FIX: correct display of percentages * FIX: correct display of percentages * FIX: correct display of percentages --- app/models/family.rb | 2 +- app/views/pages/dashboard.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/family.rb b/app/models/family.rb index fef014fe..f2f1b530 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -149,7 +149,7 @@ class Family < ApplicationRecord savings << { date: r.date, - value: r.rolling_income != 0 ? (r.rolling_income - r.rolling_spend) / r.rolling_income : 0.to_d + value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d } end diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 06a9115e..fe6639fe 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -133,7 +133,7 @@ <%= render partial: "shared/value_heading", locals: { label: t(".savings_rate"), period: Period.last_30_days, - value: @savings_rate_series.last&.value, + value: (@savings_rate_series.last&.value)*100, trend: @savings_rate_series.trend, is_percentage: true } %> From 8c8e972dc876dec52ed3b489f6d372474d6edae4 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 17 Jan 2025 17:01:26 -0500 Subject: [PATCH 116/626] Bump to v0.3.0 Signed-off-by: Zach Gollwitzer --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index a81c355e..93dcef56 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -10,7 +10,7 @@ module Maybe private def semver - "0.2.0" + "0.3.0" end end end 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 117/626] 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 118/626] 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 119/626] 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 120/626] 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 121/626] 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 122/626] 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 123/626] 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 124/626] 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 125/626] 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 126/626] 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 127/626] 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 128/626] 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 129/626] 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 130/626] 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 131/626] 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 132/626] 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 133/626] 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 134/626] 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 135/626] 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 136/626] 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 137/626] 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 138/626] 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 139/626] 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 140/626] 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 141/626] 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 142/626] 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 143/626] 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 144/626] 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 145/626] 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 146/626] 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 147/626] 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 148/626] 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 149/626] 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 150/626] 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 151/626] 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 152/626] 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 153/626] 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 154/626] 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 155/626] 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 156/626] 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 157/626] 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 158/626] 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 159/626] 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 160/626] 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 161/626] 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 162/626] 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 163/626] 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 164/626] 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 165/626] 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 @@