From 8648f11413e6cd3c50d80a6ffb8747aad61bed46 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 11 Apr 2025 12:13:46 -0400 Subject: [PATCH 001/275] Sync hierarchy updates (#2087) * Add parent sync orchestration * Pass sync object to dependents --- app/jobs/sync_job.rb | 1 - app/models/account.rb | 18 ++++---- app/models/concerns/accountable.rb | 2 +- app/models/concerns/syncable.rb | 8 ++-- app/models/family.rb | 17 ++++--- app/models/plaid_item.rb | 4 +- app/models/sync.rb | 45 +++++++++++++++++-- db/migrate/20250411140604_add_parent_syncs.rb | 5 +++ db/schema.rb | 5 ++- test/models/family_test.rb | 6 +-- test/models/sync_test.rb | 30 ++++++++++++- 11 files changed, 111 insertions(+), 30 deletions(-) create mode 100644 db/migrate/20250411140604_add_parent_syncs.rb diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index 3c7497df..0d9b848c 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -2,7 +2,6 @@ class SyncJob < ApplicationJob queue_as :high_priority def perform(sync) - sleep 1 # simulate work for faster jobs sync.perform end end diff --git a/app/models/account.rb b/app/models/account.rb index 5e1383e4..cd1bd8aa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -70,24 +70,26 @@ class Account < ApplicationRecord DestroyJob.perform_later(self) end - def sync_data(start_date: nil) + def sync_data(sync, start_date: nil) update!(last_synced_at: Time.current) - Rails.logger.info("Auto-matching transfers") - family.auto_match_transfers! - Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})") sync_balances + end + + def post_sync(sync) + family.remove_syncing_notice! + + accountable.post_sync(sync) if enrichable? Rails.logger.info("Enriching transaction data") enrich_data end - end - def post_sync - broadcast_remove_to(family, target: "syncing-notice") - accountable.post_sync + unless sync.child? + family.auto_match_transfers! + end end def original_balance diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index c3e2b783..2d545ec9 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -45,7 +45,7 @@ module Accountable end end - def post_sync + def post_sync(sync) broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 042eb6b1..0ae45122 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -9,8 +9,8 @@ module Syncable syncs.where(status: [ :syncing, :pending ]).any? end - def sync_later(start_date: nil) - new_sync = syncs.create!(start_date: start_date) + def sync_later(start_date: nil, parent_sync: nil) + new_sync = syncs.create!(start_date: start_date, parent: parent_sync) SyncJob.perform_later(new_sync) end @@ -18,11 +18,11 @@ module Syncable syncs.create!(start_date: start_date).perform end - def sync_data(start_date: nil) + def sync_data(sync, start_date: nil) raise NotImplementedError, "Subclasses must implement the `sync_data` method" end - def post_sync + def post_sync(sync) # no-op, syncable can optionally provide implementation end diff --git a/app/models/family.rb b/app/models/family.rb index 0b4405e8..413c969b 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -43,29 +43,36 @@ class Family < ApplicationRecord @income_statement ||= IncomeStatement.new(self) end - def sync_data(start_date: nil) + def sync_data(sync, start_date: nil) update!(last_synced_at: Time.current) accounts.manual.each do |account| - account.sync_later(start_date: start_date) + account.sync_later(start_date: start_date, parent_sync: sync) end plaid_items.each do |plaid_item| - plaid_item.sync_later(start_date: start_date) + plaid_item.sync_later(start_date: start_date, parent_sync: sync) end end - def post_sync + def remove_syncing_notice! + broadcast_remove target: "syncing-notice" + end + + def post_sync(sync) + auto_match_transfers! broadcast_refresh end + # If family has any syncs pending/syncing within the last hour, we show a persistent "syncing" notice. + # Ignore syncs older than 1 hour as they are considered "stale" def 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? + ).where(status: [ "pending", "syncing" ], created_at: 1.hour.ago..).exists? end def eu? diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index b990729a..93a3a13e 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -37,7 +37,7 @@ class PlaidItem < ApplicationRecord end end - def sync_data(start_date: nil) + def sync_data(sync, start_date: nil) update!(last_synced_at: Time.current) begin @@ -79,7 +79,7 @@ class PlaidItem < ApplicationRecord end end - def post_sync + def post_sync(sync) family.broadcast_refresh end diff --git a/app/models/sync.rb b/app/models/sync.rb index 64446cdd..cf11c313 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -1,32 +1,71 @@ class Sync < ApplicationRecord belongs_to :syncable, polymorphic: true + belongs_to :parent, class_name: "Sync", optional: true + has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } scope :ordered, -> { order(created_at: :desc) } + def child? + parent_id.present? + end + def perform Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do start! begin - data = syncable.sync_data(start_date: start_date) + data = syncable.sync_data(self, start_date: start_date) update!(data: data) if data - complete! + + complete! unless has_pending_child_syncs? rescue StandardError => error fail! error raise error if Rails.env.development? ensure Rails.logger.info("Sync completed, starting post-sync") - syncable.post_sync + if has_parent? + notify_parent_of_completion! + else + syncable.post_sync(self) + end Rails.logger.info("Post-sync completed") end end end + def handle_child_completion_event + unless has_pending_child_syncs? + if has_failed_child_syncs? + fail!("One or more child syncs failed") + else + complete! + syncable.post_sync(self) + end + end + end + private + def has_pending_child_syncs? + children.where(status: [ :pending, :syncing ]).any? + end + + def has_failed_child_syncs? + children.where(status: :failed).any? + end + + def has_parent? + parent_id.present? + end + + def notify_parent_of_completion! + parent.handle_child_completion_event + end + def start! Rails.logger.info("Starting sync") update! status: :syncing diff --git a/db/migrate/20250411140604_add_parent_syncs.rb b/db/migrate/20250411140604_add_parent_syncs.rb new file mode 100644 index 00000000..113d82ef --- /dev/null +++ b/db/migrate/20250411140604_add_parent_syncs.rb @@ -0,0 +1,5 @@ +class AddParentSyncs < ActiveRecord::Migration[7.2] + def change + add_reference :syncs, :parent, foreign_key: { to_table: :syncs }, type: :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index 5e2a0c09..5bd3bb37 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_04_10_144939) do +ActiveRecord::Schema[7.2].define(version: 2025_04_11_140604) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -547,6 +547,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_10_144939) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.text "error_backtrace", array: true + t.uuid "parent_id" + t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end @@ -665,6 +667,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_10_144939) do add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" + add_foreign_key "syncs", "syncs", column: "parent_id" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" add_foreign_key "tool_calls", "messages" diff --git a/test/models/family_test.rb b/test/models/family_test.rb index da50fb02..1488234e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -17,13 +17,13 @@ class FamilyTest < ActiveSupport::TestCase items_count = @syncable.plaid_items.count Account.any_instance.expects(:sync_later) - .with(start_date: nil) + .with(start_date: nil, parent_sync: family_sync) .times(manual_accounts_count) PlaidItem.any_instance.expects(:sync_later) - .with(start_date: nil) + .with(start_date: nil, parent_sync: family_sync) .times(items_count) - @syncable.sync_data(start_date: family_sync.start_date) + @syncable.sync_data(family_sync, start_date: family_sync.start_date) end end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 5fdf5898..ec4482b6 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -7,7 +7,7 @@ class SyncTest < ActiveSupport::TestCase end test "runs successful sync" do - @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).once + @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).once assert_equal "pending", @sync.status @@ -20,7 +20,7 @@ class SyncTest < ActiveSupport::TestCase end test "handles sync errors" do - @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).raises(StandardError.new("test sync error")) + @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error")) assert_equal "pending", @sync.status previously_ran_at = @sync.last_ran_at @@ -31,4 +31,30 @@ class SyncTest < ActiveSupport::TestCase assert_equal "failed", @sync.status assert_equal "test sync error", @sync.error end + + test "runs sync with child syncs" do + family = families(:dylan_family) + + parent = Sync.create!(syncable: family) + child1 = Sync.create!(syncable: family.accounts.first, parent: parent) + child2 = Sync.create!(syncable: family.accounts.last, parent: parent) + + parent.syncable.expects(:sync_data).returns([]).once + child1.syncable.expects(:sync_data).returns([]).once + child2.syncable.expects(:sync_data).returns([]).once + + parent.perform # no-op + + assert_equal "syncing", parent.status + assert_equal "pending", child1.status + assert_equal "pending", child2.status + + child1.perform + assert_equal "completed", child1.status + assert_equal "syncing", parent.status + + child2.perform + assert_equal "completed", child2.status + assert_equal "completed", parent.status + end end From 48c8499b702169b02e526281fce941bb00479c93 Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 11 Apr 2025 18:14:21 +0200 Subject: [PATCH 002/275] Add tags selection and notes input to new transaction form (#2008) * feat: Add tags selection and notes input to new transaction form * feat: Add tag selection to transactions bulk update form --- .../account/transactions_controller.rb | 2 +- app/models/account/entry.rb | 3 ++- app/views/account/transactions/_form.html.erb | 18 ++++++++++++++++++ .../account/transactions/bulk_edit.html.erb | 1 + .../locales/views/account/transactions/en.yml | 6 ++++++ .../account/transactions_controller_test.rb | 2 ++ 6 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb index 028fd5d0..8125565c 100644 --- a/app/controllers/account/transactions_controller.rb +++ b/app/controllers/account/transactions_controller.rb @@ -27,7 +27,7 @@ class Account::TransactionsController < ApplicationController end def bulk_update_params - params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: []) + params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) end def search_params diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b53db19b..4e6292a1 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -67,7 +67,8 @@ class Account::Entry < ApplicationRecord notes: bulk_update_params[:notes], entryable_attributes: { category_id: bulk_update_params[:category_id], - merchant_id: bulk_update_params[:merchant_id] + merchant_id: bulk_update_params[:merchant_id], + tag_ids: bulk_update_params[:tag_ids] }.compact_blank }.compact_blank diff --git a/app/views/account/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb index fd8d404c..211738c5 100644 --- a/app/views/account/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -31,6 +31,24 @@ <%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %> + <%= disclosure t(".details"), default_open: false do %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.select :tag_ids, + Current.family.tags.alphabetically.pluck(:name, :id), + { + include_blank: t(".none"), + multiple: true, + label: t(".tags_label"), + container_class: "h-40" + }%> + <% end %> + <%= f.text_area :notes, + label: t(".note_label"), + placeholder: t(".note_placeholder"), + rows: 5, + "data-auto-submit-form-target": "auto" %> + <% end %> +
<%= f.submit t(".submit") %>
diff --git a/app/views/account/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb index c42faf50..7c0a7e0f 100644 --- a/app/views/account/transactions/bulk_edit.html.erb +++ b/app/views/account/transactions/bulk_edit.html.erb @@ -40,6 +40,7 @@
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-subdued" } %> <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-subdued" } %> + <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: t(".none"), multiple: true, label: t(".tag_label"), container_class: "h-40" } %> <%= form.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5 %>
diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index 397c7bb5..a5f979e3 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -12,10 +12,12 @@ en: details: Details merchant_label: Merchant merchant_placeholder: Select a merchant + none: (none) note_label: Notes note_placeholder: Enter a note that will be applied to selected transactions overview: Overview save: Save + tag_label: Tags bulk_update: success: "%{count} transactions updated" form: @@ -29,7 +31,11 @@ en: description_placeholder: Describe transaction expense: Expense income: Income + none: (none) + note_label: Notes + note_placeholder: Enter a note submit: Add transaction + tags_label: Tags transfer: Transfer new: new_transaction: New transaction diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index d490bfa7..bb1d9b83 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -99,6 +99,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest date: 1.day.ago.to_date, category_id: Category.second.id, merchant_id: Merchant.second.id, + tag_ids: [ Tag.first.id, Tag.second.id ], notes: "Updated note" } } @@ -112,6 +113,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal Category.second, transaction.account_transaction.category assert_equal Merchant.second, transaction.account_transaction.merchant assert_equal "Updated note", transaction.notes + assert_equal [ Tag.first.id, Tag.second.id ], transaction.entryable.tag_ids.sort end end end From 1e01840feefcc4f2ce9370a2bc4f5cc4cd570cb4 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 14 Apr 2025 08:41:49 -0400 Subject: [PATCH 003/275] Chromium E2E test fixes (#2108) * Change test password to avoid chromium conflicts * Update integration tests * Centralize all test password references * Remove unrelated schema changes --- db/schema.rb | 2 +- test/application_system_test_case.rb | 2 +- test/controllers/mfa_controller_test.rb | 8 ++++---- test/controllers/sessions_controller_test.rb | 2 +- test/fixtures/users.yml | 10 +++++----- test/test_helper.rb | 6 +++++- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 5bd3bb37..8c37862a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -101,7 +101,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_140604) 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)::text, ('CreditCard'::character varying)::text, ('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, '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 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index a41ff371..a65f724e 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -13,7 +13,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase visit new_session_path within "form" do fill_in "Email", with: user.email - fill_in "Password", with: "password" + fill_in "Password", with: user_password_test click_on "Log in" end diff --git a/test/controllers/mfa_controller_test.rb b/test/controllers/mfa_controller_test.rb index 28b52dee..e2e24417 100644 --- a/test/controllers/mfa_controller_test.rb +++ b/test/controllers/mfa_controller_test.rb @@ -54,7 +54,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } assert_redirected_to verify_mfa_path get verify_mfa_path @@ -67,7 +67,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } totp = ROTP::TOTP.new(@user.otp_secret, issuer: "Maybe") post verify_mfa_path, params: { code: totp.now } @@ -81,7 +81,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } backup_code = @user.otp_backup_codes.first post verify_mfa_path, params: { code: backup_code } @@ -96,7 +96,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } post verify_mfa_path, params: { code: "invalid" } assert_response :unprocessable_entity diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index b0e91f62..8383ac0b 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -42,7 +42,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! @user.sessions.destroy_all # Clean up any existing sessions - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } assert_redirected_to verify_mfa_path assert_equal @user.id, session[:mfa_user_id] diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index ef3e7e3d..e7088ec9 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,7 +3,7 @@ empty: first_name: User last_name: One email: user1@email.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -12,7 +12,7 @@ maybe_support_staff: first_name: Support last_name: Admin email: support@maybefinance.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: super_admin onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -22,7 +22,7 @@ family_admin: first_name: Bob last_name: Dylan email: bob@bobdylan.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: admin onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -32,7 +32,7 @@ family_member: first_name: Jakob last_name: Dylan email: jakobdylan@yahoo.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -42,6 +42,6 @@ new_email: last_name: User email: user@example.com unconfirmed_email: new@example.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= Time.current %> ai_enabled: true \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index 9e1bb2c9..40bf14c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -49,7 +49,7 @@ module ActiveSupport # Add more helper methods to be used by all tests here... def sign_in(user) - post sessions_path, params: { email: user.email, password: "password" } + post sessions_path, params: { email: user.email, password: user_password_test } end def with_env_overrides(overrides = {}, &block) @@ -60,6 +60,10 @@ module ActiveSupport Rails.configuration.stubs(:app_mode).returns("self_hosted".inquiry) yield end + + def user_password_test + "maybetestpassword817983172" + end end end From b06fd1edf0d78734a8e45536ffd9b0926cdf63c5 Mon Sep 17 00:00:00 2001 From: busybox <29630035+busybox11@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:47:54 +0200 Subject: [PATCH 004/275] Small count fix in hosting section (#2094) Signed-off-by: busybox <29630035+busybox11@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a384012..c0dd6e65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ and eventually offer a hosted version of the app for a small monthly fee. ## Maybe Hosting -There are 3 primary ways to use the Maybe app: +There are 2 primary ways to use the Maybe app: 1. Managed (easiest) - we're in alpha and release invites in our Discord 2. [Self-host with Docker](docs/hosting/docker.md) From e51712706215b9f8187fe7e009c0fbc70ac87b7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 08:52:30 -0400 Subject: [PATCH 005/275] Bump csv from 3.3.3 to 3.3.4 (#2107) Bumps [csv](https://github.com/ruby/csv) from 3.3.3 to 3.3.4. - [Release notes](https://github.com/ruby/csv/releases) - [Changelog](https://github.com/ruby/csv/blob/main/NEWS.md) - [Commits](https://github.com/ruby/csv/compare/v3.3.3...v3.3.4) --- updated-dependencies: - dependency-name: csv dependency-version: 3.3.4 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 8f02f953..e6f8cc44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -139,7 +139,7 @@ GEM bigdecimal rexml crass (1.0.6) - csv (3.3.3) + csv (3.3.4) date (3.4.1) debug (1.10.0) irb (~> 1.10) From f23569717825dea426c515c79580bd15c6d06ddb Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Mon, 14 Apr 2025 15:05:25 +0200 Subject: [PATCH 006/275] Fix: Fix unalble to reject automatched transfers (#2102) Co-authored-by: Zach Gollwitzer --- app/controllers/transfers_controller.rb | 2 +- test/controllers/transfers_controller_test.rb | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index c20ccacc..895dcfa2 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -38,7 +38,7 @@ class TransfersController < ApplicationController def update Transfer.transaction do update_transfer_status - update_transfer_details + update_transfer_details unless transfer_update_params[:status] == "rejected" end respond_to do |format| diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 3350b2c6..9959f45b 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -41,4 +41,24 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "Transfer updated", flash[:notice] assert_equal "Test notes", transfer.reload.notes end + + test "handles rejection without FrozenError" do + transfer = transfers(:one) + + assert_difference "Transfer.count", -1 do + patch transfer_url(transfer), params: { + transfer: { + status: "rejected" + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer updated", flash[:notice] + + # Verify the transfer was actually destroyed + assert_raises(ActiveRecord::RecordNotFound) do + transfer.reload + end + end end From 6f70a54d6f0255ab83153f728d4687212ef72a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:05:33 -0400 Subject: [PATCH 007/275] Bump faraday from 2.12.2 to 2.13.0 (#2106) Bumps [faraday](https://github.com/lostisland/faraday) from 2.12.2 to 2.13.0. - [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.2...v2.13.0) --- updated-dependencies: - dependency-name: faraday dependency-version: 2.13.0 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 e6f8cc44..c039b969 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -161,7 +161,7 @@ GEM event_stream_parser (1.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.2) + faraday (2.13.0) faraday-net_http (>= 2.0, < 3.5) json logger From 5cb2183bdf2e573470baad8fd0e169f5278e95db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:05:41 -0400 Subject: [PATCH 008/275] Bump stripe from 14.0.0 to 15.0.0 (#2105) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 14.0.0 to 15.0.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/v14.0.0...v15.0.0) --- updated-dependencies: - dependency-name: stripe dependency-version: 15.0.0 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> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c039b969..dac7842a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -477,7 +477,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) - stripe (14.0.0) + stripe (15.0.0) tailwindcss-rails (4.2.1) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) From f181ba941f88d644226da7b469f3600f2b6cdf11 Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Mon, 14 Apr 2025 09:09:25 -0400 Subject: [PATCH 009/275] loan: Set the first valuation as the original principal. (#2088) Fix: #1645. --- app/controllers/loans_controller.rb | 2 +- app/models/account.rb | 35 +++++++++++++++---- app/models/loan.rb | 10 ++++-- app/views/loans/_form.html.erb | 7 ++++ app/views/loans/_overview.html.erb | 2 +- config/locales/views/loans/en.yml | 1 + ...0250405210514_add_initial_balance_field.rb | 5 +++ db/schema.rb | 1 + test/controllers/loans_controller_test.rb | 8 +++-- test/system/accounts_test.rb | 1 + 10 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20250405210514_add_initial_balance_field.rb diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index b9968faf..961c5acf 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -2,6 +2,6 @@ class LoansController < ApplicationController include AccountableResource permitted_accountable_attributes( - :id, :rate_type, :interest_rate, :term_months + :id, :rate_type, :interest_rate, :term_months, :initial_balance ) end diff --git a/app/models/account.rb b/app/models/account.rb index cd1bd8aa..b93a13e1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -34,6 +34,7 @@ class Account < ApplicationRecord def create_and_sync(attributes) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty account = new(attributes.merge(cash_balance: attributes[:balance])) + initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0 transaction do # Create 2 valuations for new accounts to establish a value history for users to see @@ -47,7 +48,7 @@ class Account < ApplicationRecord account.entries.build( name: "Initial Balance", date: 1.day.ago.to_date, - amount: 0, + amount: initial_balance, currency: account.currency, entryable: Account::Valuation.new ) @@ -92,11 +93,6 @@ class Account < ApplicationRecord end end - def original_balance - balance_amount = balances.chronological.first&.balance || balance - Money.new(balance_amount, currency) - end - def current_holdings holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end @@ -104,9 +100,13 @@ class Account < ApplicationRecord def update_with_sync!(attributes) should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance + initial_balance = attributes.dig(:accountable_attributes, :initial_balance) + should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance + transaction do update!(attributes) update_balance!(attributes[:balance]) if should_update_balance + update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance end sync_later @@ -127,11 +127,34 @@ class Account < ApplicationRecord end end + def update_inital_balance!(initial_balance) + valuation = first_valuation + + if valuation + valuation.update! amount: initial_balance + else + entries.create! \ + date: Date.current, + name: "Initial Balance", + amount: initial_balance, + currency: currency, + entryable: Account::Valuation.new + end + end + def start_date first_entry_date = entries.minimum(:date) || Date.current first_entry_date - 1.day end + def first_valuation + entries.account_valuations.order(:date).first + end + + def first_valuation_amount + first_valuation&.amount_money || balance_money + end + private def sync_balances strategy = linked? ? :reverse : :forward diff --git a/app/models/loan.rb b/app/models/loan.rb index 14b4d084..283e112e 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -3,20 +3,24 @@ class Loan < ApplicationRecord def monthly_payment return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed" - return Money.new(0, account.currency) if account.original_balance.amount.zero? || term_months.zero? + return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero? annual_rate = interest_rate / 100.0 monthly_rate = annual_rate / 12.0 if monthly_rate.zero? - payment = account.original_balance.amount / term_months + payment = account.loan.original_balance.amount / term_months else - payment = (account.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1) + payment = (account.loan.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1) end Money.new(payment.round, account.currency) end + def original_balance + Money.new(account.first_valuation_amount, account.currency) + end + class << self def color "#D444F1" diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index bceee968..73dd7bb7 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -5,6 +5,13 @@
<%= form.fields_for :accountable do |loan_form| %> +
+ <%= loan_form.money_field :initial_balance, + label: t("loans.form.initial_balance"), + default_currency: Current.family.currency, + required: true %> +
+
<%= loan_form.number_field :interest_rate, label: t("loans.form.interest_rate"), diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb index db6824fb..bbeccb6e 100644 --- a/app/views/loans/_overview.html.erb +++ b/app/views/loans/_overview.html.erb @@ -2,7 +2,7 @@
<%= summary_card title: t(".original_principal") do %> - <%= format_money account.original_balance %> + <%= format_money account.loan.original_balance %> <% end %> <%= summary_card title: t(".remaining_principal") do %> diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index 930af8df..33eb76f3 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -6,6 +6,7 @@ en: form: interest_rate: Interest rate interest_rate_placeholder: '5.25' + initial_balance: Original loan balance rate_type: Rate type term_months: Term (months) term_months_placeholder: '360' diff --git a/db/migrate/20250405210514_add_initial_balance_field.rb b/db/migrate/20250405210514_add_initial_balance_field.rb new file mode 100644 index 00000000..5ddb974b --- /dev/null +++ b/db/migrate/20250405210514_add_initial_balance_field.rb @@ -0,0 +1,5 @@ +class AddInitialBalanceField < ActiveRecord::Migration[7.2] + def change + add_column :loans, :initial_balance, :decimal, precision: 19, scale: 4 + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c37862a..a4252e7e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -378,6 +378,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_140604) do t.string "rate_type" t.decimal "interest_rate", precision: 10, scale: 3 t.integer "term_months" + t.decimal "initial_balance", precision: 19, scale: 4 end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb index 627d82ac..774c5d1a 100644 --- a/test/controllers/loans_controller_test.rb +++ b/test/controllers/loans_controller_test.rb @@ -22,7 +22,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest accountable_attributes: { interest_rate: 5.5, term_months: 60, - rate_type: "fixed" + rate_type: "fixed", + initial_balance: 50000 } } } @@ -36,6 +37,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_equal 5.5, created_account.accountable.interest_rate assert_equal 60, created_account.accountable.term_months assert_equal "fixed", created_account.accountable.rate_type + assert_equal 50000, created_account.accountable.initial_balance assert_redirected_to created_account assert_equal "Loan account created", flash[:notice] @@ -54,7 +56,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest id: @account.accountable_id, interest_rate: 4.5, term_months: 48, - rate_type: "fixed" + rate_type: "fixed", + initial_balance: 48000 } } } @@ -67,6 +70,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_equal 4.5, @account.accountable.interest_rate assert_equal 48, @account.accountable.term_months assert_equal "fixed", @account.accountable.rate_type + assert_equal 48000, @account.accountable.initial_balance assert_redirected_to @account assert_equal "Loan account updated", flash[:notice] diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index ff8d4500..70ed7492 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -59,6 +59,7 @@ class AccountsTest < ApplicationSystemTestCase test "can create loan account" do assert_account_created "Loan" do + fill_in "account[accountable_attributes][initial_balance]", with: 1000 fill_in "Interest rate", with: 5.25 select "Fixed", from: "Rate type" fill_in "Term (months)", with: 360 From e657c40d195012e396824cd346df1f0df8ef4e44 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 14 Apr 2025 11:40:34 -0400 Subject: [PATCH 010/275] Account:: namespace simplifications and cleanup (#2110) * Flatten Holding model * Flatten balance model * Entries domain renames * Fix valuations reference * Fix trades stream * Fix brakeman warnings * Fix tests * Replace existing entryable type references in DB --- .cursor/rules/project-conventions.mdc | 14 +- .cursor/rules/project-design.mdc | 24 +-- app/controllers/account/trades_controller.rb | 37 ---- .../transaction_categories_controller.rb | 22 --- .../account/transactions_controller.rb | 37 ---- .../account/valuations_controller.rb | 3 - .../budget_categories_controller.rb | 6 +- .../concerns/entryable_resource.rb | 97 +--------- app/controllers/concerns/stream_extensions.rb | 20 +++ .../{account => }/holdings_controller.rb | 2 +- app/controllers/trades_controller.rb | 79 ++++++++ .../transaction_categories_controller.rb | 22 +++ .../transactions/bulk_deletions_controller.rb | 12 ++ .../transactions/bulk_updates_controller.rb | 19 ++ app/controllers/transactions_controller.rb | 57 +++++- .../transfer_matches_controller.rb | 16 +- app/controllers/valuations_controller.rb | 49 +++++ app/helpers/{account => }/entries_helper.rb | 8 +- app/models/account.rb | 20 +-- app/models/account/chartable.rb | 4 +- app/models/account/enrichable.rb | 8 +- app/models/account/valuation.rb | 3 - app/models/account_import.rb | 2 +- app/models/{account => }/balance.rb | 2 +- .../{account => }/balance/base_calculator.rb | 6 +- .../balance/forward_calculator.rb | 2 +- .../balance/reverse_calculator.rb | 2 +- .../{account => }/balance/sync_cache.rb | 6 +- app/models/{account => }/balance/syncer.rb | 10 +- .../trend_calculator.rb} | 6 +- app/models/category.rb | 2 +- app/models/demo/generator.rb | 12 +- app/models/{account => }/entry.rb | 14 +- app/models/{account => }/entry_search.rb | 14 +- app/models/{account => }/entryable.rb | 12 +- app/models/family/auto_transfer_matchable.rb | 8 +- app/models/{account => }/holding.rb | 8 +- .../{account => }/holding/base_calculator.rb | 8 +- .../holding/forward_calculator.rb | 4 +- .../{account => }/holding/gapfillable.rb | 4 +- .../{account => }/holding/portfolio_cache.rb | 4 +- .../holding/reverse_calculator.rb | 4 +- app/models/{account => }/holding/syncer.rb | 8 +- app/models/import.rb | 2 +- app/models/import/row.rb | 2 +- app/models/income_statement/base_query.rb | 6 +- app/models/merchant.rb | 2 +- app/models/mint_import.rb | 2 +- app/models/plaid_account.rb | 4 +- app/models/plaid_investment_sync.rb | 4 +- app/models/property.rb | 2 +- app/models/rejected_transfer.rb | 4 +- app/models/security.rb | 2 +- app/models/tag.rb | 2 +- app/models/{account => }/trade.rb | 4 +- app/models/{account => }/trade_builder.rb | 8 +- app/models/trade_import.rb | 6 +- app/models/{account => }/transaction.rb | 6 +- .../{account => }/transaction/provided.rb | 2 +- .../search.rb} | 22 +-- .../{account => }/transaction/transferable.rb | 2 +- app/models/transaction_import.rb | 6 +- app/models/transfer.rb | 12 +- app/models/valuation.rb | 3 + app/models/vehicle.rb | 2 +- .../account/transactions/_header.html.erb | 23 --- app/views/account/transactions/new.html.erb | 3 - app/views/accounts/show/_activity.html.erb | 8 +- app/views/category/dropdowns/_row.html.erb | 6 +- app/views/category/dropdowns/show.html.erb | 12 +- .../{account => }/entries/_empty.html.erb | 0 .../{account => }/entries/_entry.html.erb | 0 .../entries/_entry_group.html.erb | 0 .../{account => }/entries/_loading.html.erb | 0 .../{account => }/entries/_ruler.html.erb | 0 .../entries/_selection_bar.html.erb | 2 +- .../{account => }/holdings/_cash.html.erb | 0 .../{account => }/holdings/_holding.html.erb | 2 +- .../holdings/_missing_price_tooltip.html.erb | 0 .../{account => }/holdings/_ruler.html.erb | 0 .../{account => }/holdings/index.html.erb | 8 +- app/views/{account => }/holdings/new.html.erb | 0 .../{account => }/holdings/show.html.erb | 8 +- app/views/investments/_holdings_tab.html.erb | 4 +- app/views/{account => }/trades/_form.html.erb | 6 +- .../{account => }/trades/_header.html.erb | 2 +- .../{account => }/trades/_trade.html.erb | 2 +- app/views/{account => }/trades/new.html.erb | 2 +- app/views/{account => }/trades/show.html.erb | 14 +- .../{account => }/transactions/_form.html.erb | 6 +- app/views/transactions/_header.html.erb | 40 ++--- .../transactions/_selection_bar.html.erb | 5 +- .../transactions/_transaction.html.erb | 6 +- .../_transaction_category.html.erb | 0 .../transactions/_transfer_match.html.erb | 0 .../bulk_updates/new.html.erb} | 20 +-- app/views/transactions/index.html.erb | 28 ++- app/views/transactions/new.html.erb | 3 + .../{account => }/transactions/show.html.erb | 18 +- .../_matching_fields.html.erb | 0 .../transfer_matches/new.html.erb | 6 +- app/views/transfers/_form.html.erb | 4 +- app/views/transfers/update.turbo_stream.erb | 8 +- .../{account => }/valuations/_form.html.erb | 4 +- .../{account => }/valuations/_header.html.erb | 0 .../valuations/_valuation.html.erb | 2 +- .../{account => }/valuations/index.html.erb | 10 +- .../{account => }/valuations/new.html.erb | 0 .../{account => }/valuations/show.html.erb | 8 +- config/brakeman.ignore | 59 +----- .../locales/models/{account => }/entry/en.yml | 2 +- config/locales/views/account/entries/en.yml | 15 -- config/locales/views/account/holdings/en.yml | 38 ---- config/locales/views/account/trades/en.yml | 39 ---- .../locales/views/account/transactions/en.yml | 64 ------- .../locales/views/account/valuations/en.yml | 31 ---- config/locales/views/entries/en.yml | 14 ++ config/locales/views/holdings/en.yml | 37 ++++ config/locales/views/trades/en.yml | 38 ++++ config/locales/views/transactions/en.yml | 39 ++++ config/locales/views/valuations/en.yml | 30 ++++ config/routes.rb | 48 +++-- db/migrate/20250413141446_table_renames.rb | 50 ++++++ db/schema.rb | 168 +++++++++--------- lib/tasks/securities.rake | 16 +- .../account/transactions_controller_test.rb | 119 ------------- .../controllers/categories_controller_test.rb | 2 +- .../category/deletions_controller_test.rb | 2 +- .../credit_cards_controller_test.rb | 4 +- .../{account => }/holdings_controller_test.rb | 12 +- test/controllers/loans_controller_test.rb | 4 +- .../controllers/properties_controller_test.rb | 4 +- .../settings/hostings_controller_test.rb | 4 +- .../{account => }/trades_controller_test.rb | 68 +++---- .../bulk_deletions_controller_test.rb | 24 +++ .../bulk_updates_controller_test.rb | 35 ++++ .../transactions_controller_test.rb | 74 +++++++- .../transfer_matches_controller_test.rb | 10 +- .../valuations_controller_test.rb | 25 +-- test/controllers/vehicles_controller_test.rb | 4 +- test/fixtures/{account => }/balances.yml | 0 test/fixtures/{account => }/entries.yml | 10 +- test/fixtures/{account => }/holdings.yml | 0 test/fixtures/taggings.yml | 4 +- test/fixtures/{account => }/trades.yml | 0 test/fixtures/{account => }/transactions.yml | 0 test/fixtures/{account => }/valuations.yml | 0 .../accountable_resource_interface_test.rb | 6 +- .../entryable_resource_interface_test.rb | 6 +- test/models/account/balance/syncer_test.rb | 51 ------ test/models/account/convertible_test.rb | 2 +- test/models/account/entry_test.rb | 14 +- test/models/account/transaction_test.rb | 4 +- test/models/account_test.rb | 2 +- .../balance/forward_calculator_test.rb | 14 +- .../balance/reverse_calculator_test.rb | 12 +- test/models/balance/syncer_test.rb | 51 ++++++ .../family/auto_transfer_matchable_test.rb | 2 +- test/models/family_test.rb | 2 +- .../holding/forward_calculator_test.rb | 56 +++--- .../holding/portfolio_cache_test.rb | 18 +- .../holding/reverse_calculator_test.rb | 40 ++--- .../{account => }/holding/syncer_test.rb | 12 +- test/models/{account => }/holding_test.rb | 4 +- test/models/income_statement_test.rb | 2 +- test/models/plaid_investment_sync_test.rb | 10 +- test/models/trade_import_test.rb | 4 +- test/models/transaction_import_test.rb | 4 +- test/models/transfer_test.rb | 32 ++-- .../{account => }/entries_test_helper.rb | 12 +- test/system/trades_test.rb | 10 +- test/system/transactions_test.rb | 20 +-- 172 files changed, 1297 insertions(+), 1258 deletions(-) delete mode 100644 app/controllers/account/trades_controller.rb delete mode 100644 app/controllers/account/transaction_categories_controller.rb delete mode 100644 app/controllers/account/transactions_controller.rb delete mode 100644 app/controllers/account/valuations_controller.rb create mode 100644 app/controllers/concerns/stream_extensions.rb rename app/controllers/{account => }/holdings_controller.rb (92%) create mode 100644 app/controllers/trades_controller.rb create mode 100644 app/controllers/transaction_categories_controller.rb create mode 100644 app/controllers/transactions/bulk_deletions_controller.rb create mode 100644 app/controllers/transactions/bulk_updates_controller.rb rename app/controllers/{account => }/transfer_matches_controller.rb (73%) create mode 100644 app/controllers/valuations_controller.rb rename app/helpers/{account => }/entries_helper.rb (78%) delete mode 100644 app/models/account/valuation.rb rename app/models/{account => }/balance.rb (85%) rename app/models/{account => }/balance/base_calculator.rb (84%) rename app/models/{account => }/balance/forward_calculator.rb (90%) rename app/models/{account => }/balance/reverse_calculator.rb (92%) rename app/models/{account => }/balance/sync_cache.rb (83%) rename app/models/{account => }/balance/syncer.rb (86%) rename app/models/{account/balance_trend_calculator.rb => balance/trend_calculator.rb} (95%) rename app/models/{account => }/entry.rb (78%) rename app/models/{account => }/entry_search.rb (74%) rename app/models/{account => }/entryable.rb (50%) rename app/models/{account => }/holding.rb (79%) rename app/models/{account => }/holding/base_calculator.rb (88%) rename app/models/{account => }/holding/forward_calculator.rb (77%) rename app/models/{account => }/holding/gapfillable.rb (91%) rename app/models/{account => }/holding/portfolio_cache.rb (98%) rename app/models/{account => }/holding/reverse_calculator.rb (87%) rename app/models/{account => }/holding/syncer.rb (84%) rename app/models/{account => }/trade.rb (83%) rename app/models/{account => }/trade_builder.rb (94%) rename app/models/{account => }/transaction.rb (64%) rename app/models/{account => }/transaction/provided.rb (89%) rename app/models/{account/transaction_search.rb => transaction/search.rb} (77%) rename app/models/{account => }/transaction/transferable.rb (96%) create mode 100644 app/models/valuation.rb delete mode 100644 app/views/account/transactions/_header.html.erb delete mode 100644 app/views/account/transactions/new.html.erb rename app/views/{account => }/entries/_empty.html.erb (100%) rename app/views/{account => }/entries/_entry.html.erb (100%) rename app/views/{account => }/entries/_entry_group.html.erb (100%) rename app/views/{account => }/entries/_loading.html.erb (100%) rename app/views/{account => }/entries/_ruler.html.erb (100%) rename app/views/{account => }/entries/_selection_bar.html.erb (86%) rename app/views/{account => }/holdings/_cash.html.erb (100%) rename app/views/{account => }/holdings/_holding.html.erb (93%) rename app/views/{account => }/holdings/_missing_price_tooltip.html.erb (100%) rename app/views/{account => }/holdings/_ruler.html.erb (100%) rename app/views/{account => }/holdings/index.html.erb (80%) rename app/views/{account => }/holdings/new.html.erb (100%) rename app/views/{account => }/holdings/show.html.erb (95%) rename app/views/{account => }/trades/_form.html.erb (87%) rename app/views/{account => }/trades/_header.html.erb (98%) rename app/views/{account => }/trades/_trade.html.erb (97%) rename app/views/{account => }/trades/new.html.erb (52%) rename app/views/{account => }/trades/show.html.erb (91%) rename app/views/{account => }/transactions/_form.html.erb (91%) rename app/views/{account => }/transactions/_selection_bar.html.erb (86%) rename app/views/{account => }/transactions/_transaction.html.erb (94%) rename app/views/{account => }/transactions/_transaction_category.html.erb (100%) rename app/views/{account => }/transactions/_transfer_match.html.erb (100%) rename app/views/{account/transactions/bulk_edit.html.erb => transactions/bulk_updates/new.html.erb} (71%) create mode 100644 app/views/transactions/new.html.erb rename app/views/{account => }/transactions/show.html.erb (90%) rename app/views/{account => }/transfer_matches/_matching_fields.html.erb (100%) rename app/views/{account => }/transfer_matches/new.html.erb (91%) rename app/views/{account => }/valuations/_form.html.erb (71%) rename app/views/{account => }/valuations/_header.html.erb (100%) rename app/views/{account => }/valuations/_valuation.html.erb (97%) rename app/views/{account => }/valuations/index.html.erb (82%) rename app/views/{account => }/valuations/new.html.erb (100%) rename app/views/{account => }/valuations/show.html.erb (91%) rename config/locales/models/{account => }/entry/en.yml (90%) delete mode 100644 config/locales/views/account/entries/en.yml delete mode 100644 config/locales/views/account/holdings/en.yml delete mode 100644 config/locales/views/account/trades/en.yml delete mode 100644 config/locales/views/account/transactions/en.yml delete mode 100644 config/locales/views/account/valuations/en.yml create mode 100644 config/locales/views/entries/en.yml create mode 100644 config/locales/views/holdings/en.yml create mode 100644 config/locales/views/trades/en.yml create mode 100644 config/locales/views/valuations/en.yml create mode 100644 db/migrate/20250413141446_table_renames.rb delete mode 100644 test/controllers/account/transactions_controller_test.rb rename test/controllers/{account => }/holdings_controller_test.rb (61%) rename test/controllers/{account => }/trades_controller_test.rb (63%) create mode 100644 test/controllers/transactions/bulk_deletions_controller_test.rb create mode 100644 test/controllers/transactions/bulk_updates_controller_test.rb rename test/controllers/{account => }/transfer_matches_controller_test.rb (71%) rename test/controllers/{account => }/valuations_controller_test.rb (59%) rename test/fixtures/{account => }/balances.yml (100%) rename test/fixtures/{account => }/entries.yml (80%) rename test/fixtures/{account => }/holdings.yml (100%) rename test/fixtures/{account => }/trades.yml (100%) rename test/fixtures/{account => }/transactions.yml (100%) rename test/fixtures/{account => }/valuations.yml (100%) delete mode 100644 test/models/account/balance/syncer_test.rb rename test/models/{account => }/balance/forward_calculator_test.rb (79%) rename test/models/{account => }/balance/reverse_calculator_test.rb (77%) create mode 100644 test/models/balance/syncer_test.rb rename test/models/{account => }/holding/forward_calculator_test.rb (64%) rename test/models/{account => }/holding/portfolio_cache_test.rb (80%) rename test/models/{account => }/holding/reverse_calculator_test.rb (74%) rename test/models/{account => }/holding/syncer_test.rb (57%) rename test/models/{account => }/holding_test.rb (95%) rename test/support/{account => }/entries_test_helper.rb (76%) diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 2977dc33..17cee2e0 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -71,7 +71,7 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture - Always use Minitest and fixtures for testing. - Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed. -- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) +- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) - Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence #### Convention 5a: Write minimal, effective tests @@ -87,26 +87,26 @@ Below are examples of necessary vs. unnecessary tests: # GOOD!! # Necessary test - in this case, we're testing critical domain business logic test "syncs balances" do - Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once @account.expects(:start_date).returns(2.days.ago.to_date) - Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + Balance::ForwardCalculator.any_instance.expects(:calculate).returns( [ - Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") ] ) assert_difference "@account.balances.count", 2 do - Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Syncer.new(@account, strategy: :forward).sync_balances end end # BAD!! # Unnecessary test - in this case, this is simply testing ActiveRecord's functionality test "saves balance" do - balance_record = Account::Balance.new(balance: 100, currency: "USD") + balance_record = Balance.new(balance: 100, currency: "USD") assert balance_record.save end diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 41fa2210..b2d13e9f 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -55,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco ### Account Holdings -An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`. +An account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`. -For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb). +For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb). ### Account Entries -An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. +An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. -The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example: +The `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example: - A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`) - A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`) - A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account -There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records: +There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records: -- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. -- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense". -- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`. +- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. +- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense". +- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`. ### Account Transfers -A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria: +A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria: - Must be from different accounts - Must be within 4 days of each other @@ -115,10 +115,10 @@ The most important type of sync is the account sync. It is orchestrated by the - Auto-matches transfer records for the account - Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb) - - Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) + - Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) - Enriches transaction data if enabled by user -An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated. +An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated. ### Plaid Item Syncs @@ -126,7 +126,7 @@ A Plaid Item sync is an ETL (extract, transform, load) operation: 1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API 2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records -3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data. +3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data. ### Family Syncs diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb deleted file mode 100644 index fd9b7d48..00000000 --- a/app/controllers/account/trades_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TradesController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :qty, :price - - private - def build_entry - Account::TradeBuilder.new(create_entry_params) - end - - def create_entry_params - params.require(:account_entry).permit( - :account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_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 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 deleted file mode 100644 index 5920a0b3..00000000 --- a/app/controllers/account/transaction_categories_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 8125565c..00000000 --- a/app/controllers/account/transactions_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TransactionsController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] } - - 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 - - private - 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: [], tag_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 -end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb deleted file mode 100644 index 08f566f3..00000000 --- a/app/controllers/account/valuations_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::ValuationsController < ApplicationController - include EntryableResource -end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index dcd96262..e8cc83e6 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController if params[:id] == BudgetCategory.uncategorized.id @budget_category = @budget.uncategorized_budget_category - @recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil }) + @recent_transactions = @recent_transactions.where(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") + @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = 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) + @recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3) end def update diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 58519725..443b0483 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -2,14 +2,9 @@ module EntryableResource extend ActiveSupport::Concern included do - before_action :set_entry, only: %i[show update destroy] - end + include StreamExtensions, ActionView::RecordIdentifier - class_methods do - def permitted_entryable_attributes(*attrs) - @permitted_entryable_attributes = attrs if attrs.any? - @permitted_entryable_attributes ||= [ :id ] - end + before_action :set_entry, only: %i[show update destroy] end def show @@ -21,49 +16,16 @@ module EntryableResource @entry = Current.family.entries.new( account: account, currency: account ? account.currency : Current.family.currency, - entryable: entryable_type.new + entryable: entryable ) 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 + raise NotImplementedError, "Entryable resources must implement #create" 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 } - ), - turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry }) - ] - end - end - else - render :show, status: :unprocessable_entity - end + raise NotImplementedError, "Entryable resources must implement #update" end def destroy @@ -71,58 +33,15 @@ module EntryableResource @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 + redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success") 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) + def entryable + controller_name.classify.constantize.new 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, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, - entryable_attributes: self.class.permitted_entryable_attributes - ) - end end diff --git a/app/controllers/concerns/stream_extensions.rb b/app/controllers/concerns/stream_extensions.rb new file mode 100644 index 00000000..978d5bfb --- /dev/null +++ b/app/controllers/concerns/stream_extensions.rb @@ -0,0 +1,20 @@ +module StreamExtensions + extend ActiveSupport::Concern + + def stream_redirect_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, notice: notice, alert: alert) + end + + def stream_redirect_back_or_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert) + end + + private + def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil) + flash[:notice] = notice if notice.present? + flash[:alert] = alert if alert.present? + + redirect_target_url = redirect_back ? request.referer : path + render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) + end +end diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/holdings_controller.rb similarity index 92% rename from app/controllers/account/holdings_controller.rb rename to app/controllers/holdings_controller.rb index 9ded4165..db9d59b4 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,4 +1,4 @@ -class Account::HoldingsController < ApplicationController +class HoldingsController < ApplicationController before_action :set_holding, only: %i[show destroy] def index diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb new file mode 100644 index 00000000..151d62c6 --- /dev/null +++ b/app/controllers/trades_controller.rb @@ -0,0 +1,79 @@ +class TradesController < ApplicationController + include EntryableResource + + def create + @entry = build_entry + + if @entry.save + @entry.sync_account_later + + flash[:notice] = t("entries.create.success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) } + 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("entries.update.success") } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + "header_entry_#{@entry.id}", + partial: "trades/header", + locals: { entry: @entry } + ), + turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry }) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def build_entry + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + TradeBuilder.new(create_entry_params.merge(account: account)) + end + + def entry_params + params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, + entryable_attributes: [ :id, :qty, :price ] + ) + end + + def create_entry_params + params.require(:entry).permit( + :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id + ) + end + + def update_entry_params + return entry_params unless entry_params[:entryable_attributes].present? + + update_params = entry_params + update_params = update_params.merge(entryable_type: "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/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb new file mode 100644 index 00000000..f70e0aa9 --- /dev/null +++ b/app/controllers/transaction_categories_controller.rb @@ -0,0 +1,22 @@ +class TransactionCategoriesController < ApplicationController + def update + @entry = Current.family.entries.transactions.find(params[:transaction_id]) + @entry.update!(entry_params) + + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@entry) } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "category_menu_transaction_#{@entry.transaction_id}", + partial: "categories/menu", + locals: { transaction: @entry.transaction } + ) + end + end + end + + private + def entry_params + params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) + end +end diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb new file mode 100644 index 00000000..fefaf389 --- /dev/null +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -0,0 +1,12 @@ +class Transactions::BulkDeletionsController < ApplicationController + def create + 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: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted" + end + + private + def bulk_delete_params + params.require(:bulk_delete).permit(entry_ids: []) + end +end diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb new file mode 100644 index 00000000..08c4befe --- /dev/null +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -0,0 +1,19 @@ +class Transactions::BulkUpdatesController < ApplicationController + def new + end + + def create + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) + + redirect_back_or_to transactions_path, notice: "#{updated} transactions updated" + end + + private + def bulk_update_params + params.require(:bulk_update) + .permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 484c53a0..e4407ee3 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,5 +1,5 @@ class TransactionsController < ApplicationController - include ScrollFocusable + include ScrollFocusable, EntryableResource before_action :store_params!, only: :index @@ -48,7 +48,62 @@ class TransactionsController < ApplicationController redirect_to transactions_path(updated_params) end + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Transaction created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "transactions/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + private + def entry_params + entry_params = params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type, + entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ] + ) + + nature = entry_params.delete(:nature) + + if nature.present? && entry_params[:amount].present? + signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d + entry_params = entry_params.merge(amount: signed_amount) + end + + entry_params + end def search_params cleaned_params = params.fetch(:q, {}) diff --git a/app/controllers/account/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb similarity index 73% rename from app/controllers/account/transfer_matches_controller.rb rename to app/controllers/transfer_matches_controller.rb index 851f3ac3..415d9377 100644 --- a/app/controllers/account/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -1,9 +1,9 @@ -class Account::TransferMatchesController < ApplicationController +class TransferMatchesController < ApplicationController before_action :set_entry def new @accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) - @transfer_match_candidates = @entry.account_transaction.transfer_match_candidates + @transfer_match_candidates = @entry.transaction.transfer_match_candidates end def create @@ -11,7 +11,7 @@ class Account::TransferMatchesController < ApplicationController @transfer.save! @transfer.sync_account_later - redirect_back_or_to transactions_path, notice: t(".success") + redirect_back_or_to transactions_path, notice: "Transfer created" end private @@ -27,7 +27,7 @@ class Account::TransferMatchesController < ApplicationController if transfer_match_params[:method] == "new" target_account = Current.family.accounts.find(transfer_match_params[:target_account_id]) - missing_transaction = Account::Transaction.new( + missing_transaction = Transaction.new( entry: target_account.entries.build( amount: @entry.amount * -1, currency: @entry.currency, @@ -37,8 +37,8 @@ class Account::TransferMatchesController < ApplicationController ) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction, - outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction + inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction, + outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction ) transfer.status = "confirmed" transfer @@ -46,8 +46,8 @@ class Account::TransferMatchesController < ApplicationController target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id]) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction, - outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction + inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction, + outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction ) transfer.status = "confirmed" transfer diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb new file mode 100644 index 00000000..7d91a9a6 --- /dev/null +++ b/app/controllers/valuations_controller.rb @@ -0,0 +1,49 @@ +class ValuationsController < ApplicationController + include EntryableResource + + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params.merge(entryable: Valuation.new)) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Balance created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "valuations/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def entry_params + params.require(:entry) + .permit(:name, :enriched_name, :date, :amount, :currency, :notes) + end +end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/entries_helper.rb similarity index 78% rename from app/helpers/account/entries_helper.rb rename to app/helpers/entries_helper.rb index 5fac75cc..e198e6ee 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -1,8 +1,8 @@ -module Account::EntriesHelper +module EntriesHelper def entries_by_date(entries, totals: false) transfer_groups = entries.group_by do |entry| # Only check for transfer if it's a transaction - next nil unless entry.entryable_type == "Account::Transaction" + next nil unless entry.entryable_type == "Transaction" entry.entryable.transfer&.id end @@ -12,7 +12,7 @@ module Account::EntriesHelper grouped_entries else grouped_entries.reject do |e| - e.entryable_type == "Account::Transaction" && + e.entryable_type == "Transaction" && e.entryable.transfer_as_inflow.present? end end @@ -25,7 +25,7 @@ module Account::EntriesHelper next if content.blank? - render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } + render partial: "entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } end.compact.join.html_safe end diff --git a/app/models/account.rb b/app/models/account.rb index b93a13e1..42a9d67f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,11 +7,11 @@ class Account < ApplicationRecord belongs_to :import, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" - has_many :entries, dependent: :destroy, class_name: "Account::Entry" - 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, class_name: "Account::Holding" + has_many :entries, dependent: :destroy + has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction" + has_many :valuations, through: :entries, source: :entryable, source_type: "Valuation" + has_many :trades, through: :entries, source: :entryable, source_type: "Trade" + has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy monetize :balance, :cash_balance @@ -43,14 +43,14 @@ class Account < ApplicationRecord date: Date.current, amount: account.balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.entries.build( name: "Initial Balance", date: 1.day.ago.to_date, amount: initial_balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.save! @@ -113,7 +113,7 @@ class Account < ApplicationRecord end def update_balance!(balance) - valuation = entries.account_valuations.find_by(date: Date.current) + valuation = entries.valuations.find_by(date: Date.current) if valuation valuation.update! amount: balance @@ -123,7 +123,7 @@ class Account < ApplicationRecord name: "Balance update", amount: balance, currency: currency, - entryable: Account::Valuation.new + entryable: Valuation.new end end @@ -148,7 +148,7 @@ class Account < ApplicationRecord end def first_valuation - entries.account_valuations.order(:date).first + entries.valuations.order(:date).first end def first_valuation_amount diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index bac6a50e..d9a6c44b 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -7,7 +7,7 @@ module Account::Chartable series_interval = interval || period.interval - balances = Account::Balance.find_by_sql([ + balances = Balance.find_by_sql([ balance_series_query, { start_date: period.start_date, @@ -61,7 +61,7 @@ module Account::Chartable COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates FROM dates d LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql}) - LEFT JOIN account_balances ab ON ( + LEFT JOIN balances ab ON ( ab.date = d.date AND ab.currency = accounts.currency AND ab.account_id = accounts.id diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb index 260aec5a..f5f175b4 100644 --- a/app/models/account/enrichable.rb +++ b/app/models/account/enrichable.rb @@ -2,9 +2,9 @@ module Account::Enrichable extend ActiveSupport::Concern def enrich_data - total_unenriched = entries.account_transactions - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") + total_unenriched = entries.transactions + .joins("JOIN transactions at ON at.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where("entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") .count if total_unenriched > 0 @@ -63,7 +63,7 @@ module Account::Enrichable transactions.active .includes(:merchant, :category) .where( - "account_entries.enriched_at IS NULL", + "entries.enriched_at IS NULL", "OR merchant_id IS NULL", "OR category_id IS NULL" ) diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb deleted file mode 100644 index 219ecd90..00000000 --- a/app/models/account/valuation.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::Valuation < ApplicationRecord - include Account::Entryable -end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 98e7e0d0..96fdfd47 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -20,7 +20,7 @@ class AccountImport < Import currency: row.currency, date: Date.current, name: "Imported account value", - entryable: Account::Valuation.new + entryable: Valuation.new ) end end diff --git a/app/models/account/balance.rb b/app/models/balance.rb similarity index 85% rename from app/models/account/balance.rb rename to app/models/balance.rb index 5d4e3710..90c4df41 100644 --- a/app/models/account/balance.rb +++ b/app/models/balance.rb @@ -1,4 +1,4 @@ -class Account::Balance < ApplicationRecord +class Balance < ApplicationRecord include Monetizable belongs_to :account diff --git a/app/models/account/balance/base_calculator.rb b/app/models/balance/base_calculator.rb similarity index 84% rename from app/models/account/balance/base_calculator.rb rename to app/models/balance/base_calculator.rb index 7acb51e8..2d01dfe7 100644 --- a/app/models/account/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::BaseCalculator +class Balance::BaseCalculator attr_reader :account def initialize(account) @@ -13,11 +13,11 @@ class Account::Balance::BaseCalculator private def sync_cache - @sync_cache ||= Account::Balance::SyncCache.new(account) + @sync_cache ||= Balance::SyncCache.new(account) end def build_balance(date, cash_balance, holdings_value) - Account::Balance.new( + Balance.new( account_id: account.id, date: date, balance: holdings_value + cash_balance, diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb similarity index 90% rename from app/models/account/balance/forward_calculator.rb rename to app/models/balance/forward_calculator.rb index 503e5b79..d024d2c6 100644 --- a/app/models/account/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator +class Balance::ForwardCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = 0 diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb similarity index 92% rename from app/models/account/balance/reverse_calculator.rb rename to app/models/balance/reverse_calculator.rb index 151f4036..4c124ced 100644 --- a/app/models/account/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator +class Balance::ReverseCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = account.cash_balance diff --git a/app/models/account/balance/sync_cache.rb b/app/models/balance/sync_cache.rb similarity index 83% rename from app/models/account/balance/sync_cache.rb rename to app/models/balance/sync_cache.rb index 1fb7ea7f..aed2b64e 100644 --- a/app/models/account/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -1,10 +1,10 @@ -class Account::Balance::SyncCache +class Balance::SyncCache def initialize(account) @account = account end def get_valuation(date) - converted_entries.find { |e| e.date == date && e.account_valuation? } + converted_entries.find { |e| e.date == date && e.valuation? } end def get_holdings(date) @@ -12,7 +12,7 @@ class Account::Balance::SyncCache end def get_entries(date) - converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) } + converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) } end private diff --git a/app/models/account/balance/syncer.rb b/app/models/balance/syncer.rb similarity index 86% rename from app/models/account/balance/syncer.rb rename to app/models/balance/syncer.rb index 7aeaebda..362b87aa 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/balance/syncer.rb @@ -1,4 +1,4 @@ -class Account::Balance::Syncer +class Balance::Syncer attr_reader :account, :strategy def initialize(account, strategy:) @@ -7,7 +7,7 @@ class Account::Balance::Syncer end def sync_balances - Account::Balance.transaction do + Balance.transaction do sync_holdings calculate_balances @@ -26,7 +26,7 @@ class Account::Balance::Syncer private def sync_holdings - @holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings + @holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings end def update_account_info @@ -63,9 +63,9 @@ class Account::Balance::Syncer def calculator if strategy == :reverse - Account::Balance::ReverseCalculator.new(account) + Balance::ReverseCalculator.new(account) else - Account::Balance::ForwardCalculator.new(account) + Balance::ForwardCalculator.new(account) end end end diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/balance/trend_calculator.rb similarity index 95% rename from app/models/account/balance_trend_calculator.rb rename to app/models/balance/trend_calculator.rb index a9bfeb30..5fb8b406 100644 --- a/app/models/account/balance_trend_calculator.rb +++ b/app/models/balance/trend_calculator.rb @@ -2,7 +2,7 @@ # 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 +class Balance::TrendCalculator BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) class << self @@ -48,12 +48,12 @@ class Account::BalanceTrendCalculator todays_entries = entries.select { |e| e.date == entry.date } todays_entries.each_with_index do |e, idx| - if e.account_valuation? + if e.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 + balance_change = e.trade? ? 0 : multiplier * e.amount cash_change = multiplier * e.amount current_balance = prior_balance + balance_change diff --git a/app/models/category.rb b/app/models/category.rb index 2adc7788..56fd3a63 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,5 @@ class Category < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 9d62e684..181b9806 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -361,7 +361,7 @@ class Demo::Generator unknown = Security.find_by(ticker: "UNKNOWN") # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") + account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") trades = [ { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, @@ -382,7 +382,7 @@ class Demo::Generator amount: qty * price, currency: "USD", name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security) + entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security) end end @@ -450,20 +450,20 @@ class Demo::Generator entry_defaults = { date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, currency: "USD", - entryable: Account::Transaction.new(transaction_attributes) + entryable: Transaction.new(transaction_attributes) } - Account::Entry.create! entry_defaults.merge(entry_attributes) + Entry.create! entry_defaults.merge(entry_attributes) end def create_valuation!(account, date, amount) - Account::Entry.create! \ + Entry.create! \ account: account, date: date, amount: amount, currency: "USD", name: "Balance update", - entryable: Account::Valuation.new + entryable: Valuation.new end def random_family_record(model, family) diff --git a/app/models/account/entry.rb b/app/models/entry.rb similarity index 78% rename from app/models/account/entry.rb rename to app/models/entry.rb index 4e6292a1..0f35cd39 100644 --- a/app/models/account/entry.rb +++ b/app/models/entry.rb @@ -1,4 +1,4 @@ -class Account::Entry < ApplicationRecord +class Entry < ApplicationRecord include Monetizable monetize :amount @@ -7,11 +7,11 @@ class Account::Entry < ApplicationRecord belongs_to :transfer, optional: true belongs_to :import, optional: true - delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy + delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable validates :date, :name, :amount, :currency, presence: true - validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } + validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } scope :active, -> { @@ -21,7 +21,7 @@ class Account::Entry < ApplicationRecord scope :chronological, -> { order( date: :asc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc, created_at: :asc ) } @@ -29,7 +29,7 @@ class Account::Entry < ApplicationRecord scope :reverse_chronological, -> { order( date: :desc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc, created_at: :desc ) } @@ -44,7 +44,7 @@ class Account::Entry < ApplicationRecord end def balance_trend(entries, balances) - Account::BalanceTrendCalculator.new(self, entries, balances).trend + Balance::TrendCalculator.new(self, entries, balances).trend end def display_name @@ -53,7 +53,7 @@ class Account::Entry < ApplicationRecord class << self def search(params) - Account::EntrySearch.new(params).build_query(all) + EntrySearch.new(params).build_query(all) end # arbitrary cutoff date to avoid expensive sync operations diff --git a/app/models/account/entry_search.rb b/app/models/entry_search.rb similarity index 74% rename from app/models/account/entry_search.rb rename to app/models/entry_search.rb index b08c338f..bed87613 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/entry_search.rb @@ -1,4 +1,4 @@ -class Account::EntrySearch +class EntrySearch include ActiveModel::Model include ActiveModel::Attributes @@ -16,7 +16,7 @@ class Account::EntrySearch return scope if search.blank? query = scope - query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search", + query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search", search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ) query @@ -26,8 +26,8 @@ class Account::EntrySearch return scope if start_date.blank? && end_date.blank? query = scope - query = query.where("account_entries.date >= ?", start_date) if start_date.present? - query = query.where("account_entries.date <= ?", end_date) if end_date.present? + query = query.where("entries.date >= ?", start_date) if start_date.present? + query = query.where("entries.date <= ?", end_date) if end_date.present? query end @@ -38,11 +38,11 @@ class Account::EntrySearch case amount_operator when "equal" - query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs) + query = query.where("ABS(ABS(entries.amount) - ?) <= 0.01", amount.to_f.abs) when "less" - query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) < ?", amount.to_f.abs) when "greater" - query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) > ?", amount.to_f.abs) end query diff --git a/app/models/account/entryable.rb b/app/models/entryable.rb similarity index 50% rename from app/models/account/entryable.rb rename to app/models/entryable.rb index 91df5521..84ab6c12 100644 --- a/app/models/account/entryable.rb +++ b/app/models/entryable.rb @@ -1,7 +1,7 @@ -module Account::Entryable +module Entryable extend ActiveSupport::Concern - TYPES = %w[Account::Valuation Account::Transaction Account::Trade] + TYPES = %w[Valuation Transaction Trade] def self.from_type(entryable_type) entryable_type.presence_in(TYPES).constantize @@ -12,18 +12,18 @@ module Account::Entryable scope :with_entry, -> { joins(:entry) } - scope :active, -> { with_entry.merge(Account::Entry.active) } + scope :active, -> { with_entry.merge(Entry.active) } scope :in_period, ->(period) { - with_entry.where(account_entries: { date: period.start_date..period.end_date }) + with_entry.where(entries: { date: period.start_date..period.end_date }) } scope :reverse_chronological, -> { - with_entry.merge(Account::Entry.reverse_chronological) + with_entry.merge(Entry.reverse_chronological) } scope :chronological, -> { - with_entry.merge(Account::Entry.chronological) + with_entry.merge(Entry.chronological) } end end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 32fe94b4..388ba5d6 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -1,12 +1,12 @@ module Family::AutoTransferMatchable def transfer_match_candidates - Account::Entry.select([ + 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") + ]).from("entries inflow_candidates") .joins(" - JOIN account_entries outflow_candidates ON ( + JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND inflow_candidates.amount = -outflow_candidates.amount AND @@ -29,7 +29,7 @@ module Family::AutoTransferMatchable .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id) .where("inflow_accounts.is_active = true") .where("outflow_accounts.is_active = true") - .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'") + .where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") .where(existing_transfers: { id: nil }) .order("date_diff ASC") # Closest matches first end diff --git a/app/models/account/holding.rb b/app/models/holding.rb similarity index 79% rename from app/models/account/holding.rb rename to app/models/holding.rb index ba7a7e2d..fb9b001e 100644 --- a/app/models/account/holding.rb +++ b/app/models/holding.rb @@ -1,4 +1,4 @@ -class Account::Holding < ApplicationRecord +class Holding < ApplicationRecord include Monetizable, Gapfillable monetize :amount @@ -27,9 +27,9 @@ class Account::Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - 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) + avg_cost = account.entries.trades + .joins("INNER JOIN trades ON trades.id = entries.entryable_id") + .where("trades.security_id = ? AND trades.qty > 0 AND entries.date <= ?", security.id, date) .average(:price) Money.new(avg_cost || price, currency) diff --git a/app/models/account/holding/base_calculator.rb b/app/models/holding/base_calculator.rb similarity index 88% rename from app/models/account/holding/base_calculator.rb rename to app/models/holding/base_calculator.rb index 4359e9ab..47178d8b 100644 --- a/app/models/account/holding/base_calculator.rb +++ b/app/models/holding/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Holding::BaseCalculator +class Holding::BaseCalculator attr_reader :account def initialize(account) @@ -8,13 +8,13 @@ class Account::Holding::BaseCalculator def calculate Rails.logger.tagged(self.class.name) do holdings = calculate_holdings - Account::Holding.gapfill(holdings) + Holding.gapfill(holdings) end end private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def empty_portfolio @@ -49,7 +49,7 @@ class Account::Holding::BaseCalculator next end - Account::Holding.new( + Holding.new( account_id: account.id, security_id: security_id, date: date, diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb similarity index 77% rename from app/models/account/holding/forward_calculator.rb rename to app/models/holding/forward_calculator.rb index afb6b71f..d2f2e8d7 100644 --- a/app/models/account/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,7 +1,7 @@ -class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator +class Holding::ForwardCalculator < Holding::BaseCalculator private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def calculate_holdings diff --git a/app/models/account/holding/gapfillable.rb b/app/models/holding/gapfillable.rb similarity index 91% rename from app/models/account/holding/gapfillable.rb rename to app/models/holding/gapfillable.rb index e2462a6f..45c05089 100644 --- a/app/models/account/holding/gapfillable.rb +++ b/app/models/holding/gapfillable.rb @@ -1,4 +1,4 @@ -module Account::Holding::Gapfillable +module Holding::Gapfillable extend ActiveSupport::Concern class_methods do @@ -19,7 +19,7 @@ module Account::Holding::Gapfillable previous_holding = holding else # Create a new holding based on the previous day's data - filled_holdings << Account::Holding.new( + filled_holdings << Holding.new( account: previous_holding.account, security: previous_holding.security, date: date, diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb similarity index 98% rename from app/models/account/holding/portfolio_cache.rb rename to app/models/holding/portfolio_cache.rb index 224d0b83..e8d3fcec 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -1,4 +1,4 @@ -class Account::Holding::PortfolioCache +class Holding::PortfolioCache attr_reader :account, :use_holdings class SecurityNotFound < StandardError @@ -49,7 +49,7 @@ class Account::Holding::PortfolioCache PriceWithPriority = Data.define(:price, :priority) def trades - @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a + @trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a end def holdings diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb similarity index 87% rename from app/models/account/holding/reverse_calculator.rb rename to app/models/holding/reverse_calculator.rb index d3677c88..f3996e5f 100644 --- a/app/models/account/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,10 +1,10 @@ -class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator +class Holding::ReverseCalculator < Holding::BaseCalculator private # Reverse calculators will use the existing holdings as a source of security ids and prices # since it is common for a provider to supply "current day" holdings but not all the historical # trades that make up those holdings. def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true) + @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true) end def calculate_holdings diff --git a/app/models/account/holding/syncer.rb b/app/models/holding/syncer.rb similarity index 84% rename from app/models/account/holding/syncer.rb rename to app/models/holding/syncer.rb index bfccd6f0..345f2a3f 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/holding/syncer.rb @@ -1,4 +1,4 @@ -class Account::Holding::Syncer +class Holding::Syncer def initialize(account, strategy:) @account = account @strategy = strategy @@ -36,7 +36,7 @@ class Account::Holding::Syncer end def purge_stale_holdings - portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq + portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq # If there are no securities in the portfolio, delete all holdings if portfolio_security_ids.empty? @@ -50,9 +50,9 @@ class Account::Holding::Syncer def calculator if strategy == :reverse - Account::Holding::ReverseCalculator.new(account) + Holding::ReverseCalculator.new(account) else - Account::Holding::ForwardCalculator.new(account) + Holding::ForwardCalculator.new(account) end end end diff --git a/app/models/import.rb b/app/models/import.rb index 662b4cee..bb367cf9 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -34,7 +34,7 @@ class Import < ApplicationRecord has_many :rows, dependent: :destroy has_many :mappings, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :entries, dependent: :destroy, class_name: "Account::Entry" + has_many :entries, dependent: :destroy class << self def parse_csv_str(csv_str, col_sep: ",") diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 622a9d0a..350d8084 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -63,7 +63,7 @@ class Import::Row < ApplicationRecord return end - min_date = Account::Entry.min_supported_date + min_date = Entry.min_supported_date max_date = Date.current if parsed_date < min_date || parsed_date > max_date diff --git a/app/models/income_statement/base_query.rb b/app/models/income_statement/base_query.rb index d2b17b81..ef1c8a99 100644 --- a/app/models/income_statement/base_query.rb +++ b/app/models/income_statement/base_query.rb @@ -11,13 +11,13 @@ module IncomeStatement::BaseQuery COUNT(ae.id) as transactions_count, BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates FROM (#{transactions_scope.to_sql}) at - JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' LEFT JOIN categories c ON c.id = at.category_id LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( transfer_info.inflow_transaction_id = at.id OR diff --git a/app/models/merchant.rb b/app/models/merchant.rb index e363f6aa..030d9409 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -1,5 +1,5 @@ class Merchant < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" belongs_to :family validates :name, :color, :family, presence: true diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 66e3bb69..da9ced2a 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -35,7 +35,7 @@ class MintImport < Import name: row.name, currency: row.currency, notes: row.notes, - entryable: Account::Transaction.new(category: category, tags: tags), + entryable: Transaction.new(category: category, tags: tags), import: self entry.save! diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index e0e71f67..4f2b923c 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -87,7 +87,7 @@ class PlaidAccount < ApplicationRecord t.amount = plaid_txn.amount t.currency = plaid_txn.iso_currency_code t.date = plaid_txn.date - t.entryable = Account::Transaction.new( + t.entryable = Transaction.new( category: get_category(plaid_txn.personal_finance_category.primary), merchant: get_merchant(plaid_txn.merchant_name) ) @@ -120,7 +120,7 @@ class PlaidAccount < ApplicationRecord e.amount = loan_data.origination_principal_amount e.currency = account.currency e.date = loan_data.origination_date - e.entryable = Account::Valuation.new + e.entryable = Valuation.new end end end diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index bcb1f330..489b0ca1 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -31,7 +31,7 @@ class PlaidInvestmentSync t.amount = transaction.amount t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Transaction.new + t.entryable = Transaction.new end else new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| @@ -39,7 +39,7 @@ class PlaidInvestmentSync t.amount = transaction.quantity * transaction.price t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Trade.new( + t.entryable = Trade.new( security: security, qty: transaction.quantity, price: transaction.price, diff --git a/app/models/property.rb b/app/models/property.rb index b30a1071..42d2979e 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -44,6 +44,6 @@ class Property < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/models/rejected_transfer.rb b/app/models/rejected_transfer.rb index 9d1a1ce4..4c66d44d 100644 --- a/app/models/rejected_transfer.rb +++ b/app/models/rejected_transfer.rb @@ -1,4 +1,4 @@ class RejectedTransfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" end diff --git a/app/models/security.rb b/app/models/security.rb index 30abbe85..0adabd8a 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -3,7 +3,7 @@ class Security < ApplicationRecord before_save :upcase_ticker - has_many :trades, dependent: :nullify, class_name: "Account::Trade" + has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy validates :ticker, presence: true diff --git a/app/models/tag.rb b/app/models/tag.rb index 6b2fb67b..c5bdc0bc 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,7 +1,7 @@ class Tag < ApplicationRecord belongs_to :family has_many :taggings, dependent: :destroy - has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction" + has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } diff --git a/app/models/account/trade.rb b/app/models/trade.rb similarity index 83% rename from app/models/account/trade.rb rename to app/models/trade.rb index a683b2ca..5d71d978 100644 --- a/app/models/account/trade.rb +++ b/app/models/trade.rb @@ -1,5 +1,5 @@ -class Account::Trade < ApplicationRecord - include Account::Entryable, Monetizable +class Trade < ApplicationRecord + include Entryable, Monetizable monetize :price diff --git a/app/models/account/trade_builder.rb b/app/models/trade_builder.rb similarity index 94% rename from app/models/account/trade_builder.rb rename to app/models/trade_builder.rb index c632f272..9b7e0471 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -1,4 +1,4 @@ -class Account::TradeBuilder +class TradeBuilder include ActiveModel::Model attr_accessor :account, :date, :amount, :currency, :qty, @@ -46,7 +46,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Trade.new( + entryable: Trade.new( qty: signed_qty, price: price, currency: currency, @@ -74,7 +74,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end end @@ -85,7 +85,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index db917f04..d126fec0 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -16,12 +16,12 @@ class TradeImport < Import exchange_operating_mic: row.exchange_operating_mic ) - Account::Trade.new( + Trade.new( security: security, qty: row.qty, currency: row.currency.presence || mapped_account.currency, price: row.price, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -31,7 +31,7 @@ class TradeImport < Import ), ) end - Account::Trade.import!(trades, recursive: true) + Trade.import!(trades, recursive: true) end end diff --git a/app/models/account/transaction.rb b/app/models/transaction.rb similarity index 64% rename from app/models/account/transaction.rb rename to app/models/transaction.rb index e31a5607..fb5fb0df 100644 --- a/app/models/account/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,5 @@ -class Account::Transaction < ApplicationRecord - include Account::Entryable, Transferable, Provided +class Transaction < ApplicationRecord + include Entryable, Transferable, Provided belongs_to :category, optional: true belongs_to :merchant, optional: true @@ -11,7 +11,7 @@ class Account::Transaction < ApplicationRecord class << self def search(params) - Account::TransactionSearch.new(params).build_query(all) + Search.new(params).build_query(all) end end end diff --git a/app/models/account/transaction/provided.rb b/app/models/transaction/provided.rb similarity index 89% rename from app/models/account/transaction/provided.rb rename to app/models/transaction/provided.rb index 4bae0ab4..b4210e0a 100644 --- a/app/models/account/transaction/provided.rb +++ b/app/models/transaction/provided.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Provided +module Transaction::Provided extend ActiveSupport::Concern def fetch_enrichment_info diff --git a/app/models/account/transaction_search.rb b/app/models/transaction/search.rb similarity index 77% rename from app/models/account/transaction_search.rb rename to app/models/transaction/search.rb index 215c6a98..067050f4 100644 --- a/app/models/account/transaction_search.rb +++ b/app/models/transaction/search.rb @@ -1,4 +1,4 @@ -class Account::TransactionSearch +class Transaction::Search include ActiveModel::Model include ActiveModel::Attributes @@ -22,10 +22,10 @@ class Account::TransactionSearch query = apply_type_filter(query, types) query = apply_merchant_filter(query, merchants) query = apply_tag_filter(query, tags) - query = Account::EntrySearch.apply_search_filter(query, search) - query = Account::EntrySearch.apply_date_filters(query, start_date, end_date) - query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator) - query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids) + query = EntrySearch.apply_search_filter(query, search) + query = EntrySearch.apply_date_filters(query, start_date, end_date) + query = EntrySearch.apply_amount_filter(query, amount, amount_operator) + query = EntrySearch.apply_accounts_filter(query, accounts, account_ids) query end @@ -36,12 +36,12 @@ class Account::TransactionSearch LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( - transfer_info.inflow_transaction_id = account_transactions.id OR - transfer_info.outflow_transaction_id = account_transactions.id + transfer_info.inflow_transaction_id = transactions.id OR + transfer_info.outflow_transaction_id = transactions.id ) SQL end @@ -68,8 +68,8 @@ class Account::TransactionSearch return query if types.sort == [ "expense", "income", "transfer" ] transfer_condition = "transfer_info.transfer_id IS NOT NULL" - expense_condition = "account_entries.amount >= 0" - income_condition = "account_entries.amount <= 0" + expense_condition = "entries.amount >= 0" + income_condition = "entries.amount <= 0" condition = case types.sort when [ "transfer" ] diff --git a/app/models/account/transaction/transferable.rb b/app/models/transaction/transferable.rb similarity index 96% rename from app/models/account/transaction/transferable.rb rename to app/models/transaction/transferable.rb index de0b70cb..d839047a 100644 --- a/app/models/account/transaction/transferable.rb +++ b/app/models/transaction/transferable.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Transferable +module Transaction::Transferable extend ActiveSupport::Concern included do diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index cf3f6e12..dd44cca4 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -13,10 +13,10 @@ class TransactionImport < Import category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - Account::Transaction.new( + Transaction.new( category: category, tags: tags, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -28,7 +28,7 @@ class TransactionImport < Import ) end - Account::Transaction.import!(transactions, recursive: true) + Transaction.import!(transactions, recursive: true) end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 3cb1f07b..d681d581 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -1,6 +1,6 @@ class Transfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" enum :status, { pending: "pending", confirmed: "confirmed" } @@ -23,22 +23,22 @@ class Transfer < ApplicationRecord end new( - inflow_transaction: Account::Transaction.new( + inflow_transaction: Transaction.new( entry: to_account.entries.build( amount: converted_amount.amount.abs * -1, currency: converted_amount.currency.iso_code, date: date, name: "Transfer from #{from_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), - outflow_transaction: Account::Transaction.new( + outflow_transaction: Transaction.new( entry: from_account.entries.build( amount: amount.abs, currency: from_account.currency, date: date, name: "Transfer to #{to_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), status: "confirmed" diff --git a/app/models/valuation.rb b/app/models/valuation.rb new file mode 100644 index 00000000..6d1d2b4b --- /dev/null +++ b/app/models/valuation.rb @@ -0,0 +1,3 @@ +class Valuation < ApplicationRecord + include Entryable +end diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 6ba19540..255e11d6 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -31,6 +31,6 @@ class Vehicle < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb deleted file mode 100644 index 83f9d1b6..00000000 --- a/app/views/account/transactions/_header.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%# 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.account_transaction.transfer? %> - <%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %> - <% end %> -
- - - <%= I18n.l(entry.date, format: :long) %> - -<% end %> diff --git a/app/views/account/transactions/new.html.erb b/app/views/account/transactions/new.html.erb deleted file mode 100644 index 12c8f2f2..00000000 --- a/app/views/account/transactions/new.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".new_transaction") do %> - <%= render "form", entry: @entry %> -<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 88d9f78c..db399472 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -11,13 +11,13 @@ <%= tag.span t(".new") %>