diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc
index eb139262..ddc5cdc7 100644
--- a/.cursor/rules/project-conventions.mdc
+++ b/.cursor/rules/project-conventions.mdc
@@ -43,7 +43,8 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects)
- While a Rails concern _may_ offer shared functionality (i.e. "duck types"), it can also be a "one-off" concern that is only included in one place for better organization and readability.
-- When possible, models should answer questions about themselves—for example, we might have a method, `account.series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
+- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
+- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
### Convention 3: Prefer server-side solutions over client-side solutions
diff --git a/app/assets/images/logomark-color.svg b/app/assets/images/logomark-color.svg
new file mode 100644
index 00000000..2c1bf71d
--- /dev/null
+++ b/app/assets/images/logomark-color.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css
index 9153362f..ecb797a1 100644
--- a/app/assets/tailwind/application.css
+++ b/app/assets/tailwind/application.css
@@ -8,7 +8,7 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
-@utility combobox {
+.combobox {
.hw-combobox__main__wrapper,
.hw-combobox__input {
@apply w-full;
@@ -40,6 +40,35 @@
--hw-handle-offset-right: 0px;
}
+/* Typography */
+.prose {
+ @apply max-w-none;
+
+ h2 {
+ @apply text-xl font-medium;
+ }
+
+ h3 {
+ @apply text-lg font-medium;
+ }
+
+ li {
+ @apply m-0;
+ }
+
+ details {
+ @apply mb-4 rounded-xl mt-3.5;
+ }
+
+ summary {
+ @apply flex items-center gap-1;
+ }
+
+ video {
+ @apply m-0 rounded-b-xl;
+ }
+}
+
.prose--github-release-notes {
.octicon {
@apply inline-block overflow-visible align-text-bottom fill-current;
diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css
index eaaa1cb2..3f252db6 100644
--- a/app/assets/tailwind/maybe-design-system.css
+++ b/app/assets/tailwind/maybe-design-system.css
@@ -256,23 +256,43 @@
}
@utility bg-surface {
- @apply bg-gray-50 hover:bg-gray-100;
+ @apply bg-gray-50;
+}
+
+@utility bg-surface-hover {
+ @apply bg-gray-100;
}
@utility bg-surface-inset {
- @apply bg-gray-100 hover:bg-gray-200;
+ @apply bg-gray-100;
+}
+
+@utility bg-surface-inset-hover {
+ @apply bg-gray-200;
}
@utility bg-container {
- @apply bg-white hover:bg-gray-50;
+ @apply bg-white;
+}
+
+@utility bg-container-hover {
+ @apply bg-gray-50;
}
@utility bg-container-inset {
- @apply bg-gray-50 hover:bg-gray-100;
+ @apply bg-gray-50;
+}
+
+@utility bg-container-inset-hover {
+ @apply bg-gray-100;
}
@utility bg-inverse {
- @apply bg-gray-800 hover:bg-gray-700;
+ @apply bg-gray-800;
+}
+
+@utility bg-inverse-hover {
+ @apply bg-gray-700;
}
@utility bg-overlay {
@@ -291,7 +311,19 @@
@apply border-alpha-black-100;
}
+@utility border-subdued {
+ @apply border-alpha-black-50;
+}
+
@layer base {
+ form>button {
+ @apply cursor-pointer;
+ }
+
+ hr {
+ @apply text-gray-200;
+ }
+
details>summary::-webkit-details-marker {
@apply hidden;
}
@@ -337,7 +369,7 @@
}
.btn--ghost {
- @apply border border-transparent text-gray-900 hover:bg-gray-50;
+ @apply border border-transparent text-gray-900 hover:bg-gray-100;
}
.btn--destructive {
@@ -412,35 +444,6 @@
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
- /* Typography */
- .prose {
- @apply max-w-none;
-
- h2 {
- @apply text-xl font-medium;
- }
-
- h3 {
- @apply text-lg font-medium;
- }
-
- li {
- @apply m-0;
- }
-
- details {
- @apply mb-4 rounded-xl mt-3.5;
- }
-
- summary {
- @apply flex items-center gap-1;
- }
-
- video {
- @apply m-0 rounded-b-xl;
- }
- }
-
/* Tooltips */
.tooltip {
@apply hidden absolute;
diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb
index 27ebcd9a..bfda09fb 100644
--- a/app/controllers/account/holdings_controller.rb
+++ b/app/controllers/account/holdings_controller.rb
@@ -1,6 +1,4 @@
class Account::HoldingsController < ApplicationController
- layout :with_sidebar
-
before_action :set_holding, only: %i[show destroy]
def index
diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb
new file mode 100644
index 00000000..17892479
--- /dev/null
+++ b/app/controllers/accountable_sparklines_controller.rb
@@ -0,0 +1,25 @@
+class AccountableSparklinesController < ApplicationController
+ def show
+ @accountable = Accountable.from_type(params[:accountable_type]&.classify)
+
+ @series = Rails.cache.fetch(cache_key) do
+ family.accounts.active
+ .where(accountable_type: @accountable.name)
+ .balance_series(
+ currency: family.currency,
+ favorable_direction: @accountable.favorable_direction
+ )
+ end
+
+ render layout: false
+ end
+
+ private
+ def family
+ Current.family
+ end
+
+ def cache_key
+ family.build_cache_key("#{@accountable.name}_sparkline")
+ end
+end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 3c0d25e7..df2b4d3c 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -1,26 +1,11 @@
class AccountsController < ApplicationController
- layout :with_sidebar
-
- before_action :set_account, only: %i[sync]
+ before_action :set_account, only: %i[sync chart sparkline]
def index
- @manual_accounts = Current.family.accounts.manual.alphabetically
- @plaid_items = Current.family.plaid_items.ordered
- end
+ @manual_accounts = family.accounts.manual.alphabetically
+ @plaid_items = family.plaid_items.ordered
- def summary
- @period = Period.from_param(params[:period])
- snapshot = Current.family.snapshot(@period)
- @net_worth_series = snapshot[:net_worth_series]
- @asset_series = snapshot[:asset_series]
- @liability_series = snapshot[:liability_series]
- @accounts = Current.family.accounts.active
- @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
- end
-
- def list
- @period = Period.from_param(params[:period])
- render layout: false
+ render layout: "settings"
end
def sync
@@ -32,20 +17,27 @@ class AccountsController < ApplicationController
end
def chart
- @account = Current.family.accounts.find(params[:id])
render layout: "application"
end
+ def sparkline
+ render layout: false
+ end
+
def sync_all
- unless Current.family.syncing?
- Current.family.sync_later
+ unless family.syncing?
+ family.sync_later
end
redirect_to accounts_path
end
private
+ def family
+ Current.family
+ end
+
def set_account
- @account = Current.family.accounts.find(params[:id])
+ @account = family.accounts.find(params[:id])
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7c9e98ca..2951cbfd 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -22,12 +22,6 @@ class ApplicationController < ActionController::Base
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
end
- def with_sidebar
- return "turbo_rails/frame" if turbo_frame_request?
-
- "with_sidebar"
- end
-
def detect_os
user_agent = request.user_agent
@os = case user_agent
diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb
index ef83da7d..dcd96262 100644
--- a/app/controllers/budget_categories_controller.rb
+++ b/app/controllers/budget_categories_controller.rb
@@ -7,7 +7,7 @@ class BudgetCategoriesController < ApplicationController
end
def show
- @recent_transactions = @budget.entries
+ @recent_transactions = @budget.transactions
if params[:id] == BudgetCategory.uncategorized.id
@budget_category = @budget.uncategorized_budget_category
@@ -42,6 +42,7 @@ class BudgetCategoriesController < ApplicationController
end
def set_budget
- @budget = Current.family.budgets.find(params[:budget_id])
+ start_date = Budget.param_to_date(params[:budget_month_year])
+ @budget = Current.family.budgets.find_by(start_date: start_date)
end
end
diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb
index 4ea71169..992dec44 100644
--- a/app/controllers/budgets_controller.rb
+++ b/app/controllers/budgets_controller.rb
@@ -6,10 +6,6 @@ class BudgetsController < ApplicationController
end
def show
- @next_budget = @budget.next_budget
- @previous_budget = @budget.previous_budget
- @latest_budget = Budget.find_or_bootstrap(Current.family)
- render layout: with_sidebar
end
def edit
@@ -21,12 +17,6 @@ class BudgetsController < ApplicationController
redirect_to budget_budget_categories_path(@budget)
end
- def create
- start_date = Date.parse(budget_create_params[:start_date])
- @budget = Budget.find_or_bootstrap(Current.family, date: start_date)
- redirect_to budget_path(@budget)
- end
-
def picker
render partial: "budgets/picker", locals: {
family: Current.family,
@@ -44,12 +34,13 @@ class BudgetsController < ApplicationController
end
def set_budget
- @budget = Current.family.budgets.find(params[:id])
- @budget.sync_budget_categories
+ start_date = Budget.param_to_date(params[:month_year])
+ @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)
+ raise ActiveRecord::RecordNotFound unless @budget
end
def redirect_to_current_month_budget
- current_budget = Budget.find_or_bootstrap(Current.family)
+ current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)
redirect_to budget_path(current_budget)
end
end
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index a9d3d105..04ffb0e5 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -1,12 +1,12 @@
class CategoriesController < ApplicationController
- layout :with_sidebar
-
before_action :set_category, only: %i[edit update destroy]
before_action :set_categories, only: %i[update edit]
before_action :set_transaction, only: :create
def index
@categories = Current.family.categories.alphabetically
+
+ render layout: "settings"
end
def new
diff --git a/app/controllers/category/deletions_controller.rb b/app/controllers/category/deletions_controller.rb
index 57f8ee5f..e11777ab 100644
--- a/app/controllers/category/deletions_controller.rb
+++ b/app/controllers/category/deletions_controller.rb
@@ -1,6 +1,4 @@
class Category::DeletionsController < ApplicationController
- layout :with_sidebar
-
before_action :set_category
before_action :set_replacement_category, only: :create
diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb
index 2a02edb4..16467e36 100644
--- a/app/controllers/concerns/accountable_resource.rb
+++ b/app/controllers/concerns/accountable_resource.rb
@@ -4,7 +4,6 @@ module AccountableResource
included do
include ScrollFocusable
- layout :with_sidebar
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new
end
diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb
index fa279034..d9e616e4 100644
--- a/app/controllers/concerns/auto_sync.rb
+++ b/app/controllers/concerns/auto_sync.rb
@@ -13,9 +13,7 @@ module AutoSync
def family_needs_auto_sync?
return false unless Current.family.present?
- return false unless Current.family.accounts.any?
- Current.family.last_synced_at.blank? ||
- Current.family.last_synced_at.to_date < Date.current
+ (Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
end
end
diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb
index e3b7365f..58519725 100644
--- a/app/controllers/concerns/entryable_resource.rb
+++ b/app/controllers/concerns/entryable_resource.rb
@@ -2,7 +2,6 @@ module EntryableResource
extend ActiveSupport::Concern
included do
- layout :with_sidebar
before_action :set_entry, only: %i[show update destroy]
end
diff --git a/app/controllers/help/articles_controller.rb b/app/controllers/help/articles_controller.rb
deleted file mode 100644
index e5b19ff3..00000000
--- a/app/controllers/help/articles_controller.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-class Help::ArticlesController < ApplicationController
- layout :with_sidebar
-
- def show
- @article = Help::Article.find(params[:id])
-
- unless @article
- head :not_found
- end
- end
-end
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index caee3328..3fb704fe 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -10,7 +10,7 @@ class ImportsController < ApplicationController
def index
@imports = Current.family.imports
- render layout: with_sidebar
+ render layout: "settings"
end
def new
diff --git a/app/controllers/merchants_controller.rb b/app/controllers/merchants_controller.rb
index 252c5de6..cbcf1cdb 100644
--- a/app/controllers/merchants_controller.rb
+++ b/app/controllers/merchants_controller.rb
@@ -1,10 +1,10 @@
class MerchantsController < ApplicationController
- layout :with_sidebar
-
before_action :set_merchant, only: %i[edit update destroy]
def index
@merchants = Current.family.merchants.alphabetically
+
+ render layout: "settings"
end
def new
diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb
index f46fbb56..ec3071e5 100644
--- a/app/controllers/mfa_controller.rb
+++ b/app/controllers/mfa_controller.rb
@@ -47,7 +47,7 @@ class MfaController < ApplicationController
if action_name.in?(%w[verify verify_code])
"auth"
else
- "with_sidebar"
+ "settings"
end
end
end
diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb
index bf1ecae2..36948f92 100644
--- a/app/controllers/onboardings_controller.rb
+++ b/app/controllers/onboardings_controller.rb
@@ -1,5 +1,4 @@
class OnboardingsController < ApplicationController
- layout "application"
before_action :set_user
before_action :load_invitation
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index e372a8d6..61d39322 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -1,40 +1,20 @@
class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
- layout :with_sidebar, except: %i[early_access]
def dashboard
- @period = Period.from_param(params[:period])
- snapshot = Current.family.snapshot(@period)
- @net_worth_series = snapshot[:net_worth_series]
- @asset_series = snapshot[:asset_series]
- @liability_series = snapshot[:liability_series]
-
- snapshot_transactions = Current.family.snapshot_transactions
- @income_series = snapshot_transactions[:income_series]
- @spending_series = snapshot_transactions[:spending_series]
- @savings_rate_series = snapshot_transactions[:savings_rate_series]
-
- snapshot_account_transactions = Current.family.snapshot_account_transactions
- @top_spenders = snapshot_account_transactions[:top_spenders]
- @top_earners = snapshot_account_transactions[:top_earners]
- @top_savers = snapshot_account_transactions[:top_savers]
-
- @accounts = Current.family.accounts.active
- @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
- @transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological
-
- # TODO: Placeholders for trendlines
- placeholder_series_data = 10.times.map do |i|
- { date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
- end
- @investing_series = TimeSeries.new(placeholder_series_data)
+ @period = Period.from_key(params[:period], fallback: true)
+ @balance_sheet = Current.family.balance_sheet
+ @accounts = Current.family.accounts.active.with_attached_logo
end
def changelog
@release_notes = Provider::Github.new.fetch_latest_release_notes
+
+ render layout: "settings"
end
def feedback
+ render layout: "settings"
end
def early_access
diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb
index 2eb6c49b..7bb8c200 100644
--- a/app/controllers/settings/billings_controller.rb
+++ b/app/controllers/settings/billings_controller.rb
@@ -1,4 +1,6 @@
-class Settings::BillingsController < SettingsController
+class Settings::BillingsController < ApplicationController
+ layout "settings"
+
def show
@user = Current.user
end
diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb
index aae6513c..fa88d23c 100644
--- a/app/controllers/settings/hostings_controller.rb
+++ b/app/controllers/settings/hostings_controller.rb
@@ -1,4 +1,6 @@
-class Settings::HostingsController < SettingsController
+class Settings::HostingsController < ApplicationController
+ layout "settings"
+
before_action :raise_if_not_self_hosted
def show
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 4f4fc1f8..83f9e2e4 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -1,4 +1,6 @@
-class Settings::PreferencesController < SettingsController
+class Settings::PreferencesController < ApplicationController
+ layout "settings"
+
def show
@user = Current.user
end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 443ba16b..d62eecd6 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -1,4 +1,6 @@
-class Settings::ProfilesController < SettingsController
+class Settings::ProfilesController < ApplicationController
+ layout "settings"
+
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb
index 7f19d7ca..9f8eac1c 100644
--- a/app/controllers/settings/securities_controller.rb
+++ b/app/controllers/settings/securities_controller.rb
@@ -1,4 +1,6 @@
-class Settings::SecuritiesController < SettingsController
+class Settings::SecuritiesController < ApplicationController
+ layout "settings"
+
def show
end
end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
deleted file mode 100644
index e0fcac5e..00000000
--- a/app/controllers/settings_controller.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-class SettingsController < ApplicationController
- layout :with_sidebar
-end
diff --git a/app/controllers/tag/deletions_controller.rb b/app/controllers/tag/deletions_controller.rb
index a5b14f7c..5ec31a3d 100644
--- a/app/controllers/tag/deletions_controller.rb
+++ b/app/controllers/tag/deletions_controller.rb
@@ -1,6 +1,4 @@
class Tag::DeletionsController < ApplicationController
- layout :with_sidebar
-
before_action :set_tag
before_action :set_replacement_tag, only: :create
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 2b81e7cd..0794e1e7 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -1,10 +1,10 @@
class TagsController < ApplicationController
- layout :with_sidebar
-
before_action :set_tag, only: %i[edit update destroy]
def index
@tags = Current.family.tags.alphabetically
+
+ render layout: "settings"
end
def new
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index eb82f376..a1e254c4 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -1,34 +1,26 @@
class TransactionsController < ApplicationController
include ScrollFocusable
- layout :with_sidebar
-
before_action :store_params!, only: :index
def index
@q = search_params
- search_query = Current.family.transactions.search(@q).active
+ transactions_query = Current.family.transactions.active.search(@q)
- set_focused_record(search_query, params[:focused_record_id], default_per_page: 50)
+ set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
- @pagy, @transaction_entries = pagy(
- search_query.reverse_chronological.preload(
- :account,
- entryable: [
- :category, :merchant, :tags,
- :transfer_as_inflow,
- transfer_as_outflow: {
- inflow_transaction: { entry: :account },
- outflow_transaction: { entry: :account }
- }
- ]
- ),
+ @pagy, @transactions = pagy(
+ transactions_query.includes(
+ { entry: :account },
+ :category, :merchant, :tags,
+ transfer_as_outflow: { inflow_transaction: { entry: :account } },
+ transfer_as_inflow: { outflow_transaction: { entry: :account } }
+ ).reverse_chronological,
limit: params[:per_page].presence || default_params[:per_page],
params: ->(params) { params.except(:focused_record_id) }
)
- @transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact
- @totals = search_query.stats(Current.family.currency)
+ @totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
end
def clear_filter
diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb
index b66054c2..a1a7e27c 100644
--- a/app/controllers/transfers_controller.rb
+++ b/app/controllers/transfers_controller.rb
@@ -1,6 +1,4 @@
class TransfersController < ApplicationController
- layout :with_sidebar
-
before_action :set_transfer, only: %i[destroy show update]
def new
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 5bb4f45f..d8d0de8a 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -19,7 +19,10 @@ class UsersController < ApplicationController
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
- handle_redirect(t(".success"))
+ respond_to do |format|
+ format.html { handle_redirect(t(".success")) }
+ format.json { head :ok }
+ end
end
end
@@ -57,7 +60,7 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
- :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
+ :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end
diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb
index b3268fdd..3350a328 100644
--- a/app/helpers/account/entries_helper.rb
+++ b/app/helpers/account/entries_helper.rb
@@ -1,22 +1,13 @@
module Account::EntriesHelper
- def permitted_entryable_partial_path(entry, relative_partial_path)
- "account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
- end
-
- def transfer_entries(entries)
- transfers = entries.select { |e| e.transfer_id.present? }
- transfers.map(&:transfer).uniq
- end
-
- def entries_by_date(entries, transfers: [], selectable: true, totals: false)
+ def entries_by_date(entries, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
- yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ]
+ yield grouped_entries
end
next if content.blank?
- render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
+ render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
end.compact.join.html_safe
end
@@ -28,11 +19,4 @@ module Account::EntriesHelper
entry.display_name
].join(" • ")
end
-
- private
-
- def permitted_entryable_key(entry)
- permitted_entryable_paths = %w[transaction valuation trade]
- entry.entryable_name_short.presence_in(permitted_entryable_paths)
- end
end
diff --git a/app/helpers/account/holdings_helper.rb b/app/helpers/account/holdings_helper.rb
deleted file mode 100644
index c9ed7e03..00000000
--- a/app/helpers/account/holdings_helper.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module Account::HoldingsHelper
- def brokerage_cash_holding(account)
- currency = Money::Currency.new(account.currency)
-
- account.holdings.build \
- date: Date.current,
- qty: account.cash_balance,
- price: 1,
- amount: account.cash_balance,
- currency: currency.iso_code,
- security: Security.new(ticker: currency.iso_code, name: currency.name)
- end
-end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index 356798e5..eedb86c3 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -1,87 +1,6 @@
module AccountsHelper
- def period_label(period)
- return "since account creation" if period.date_range.begin.nil?
- start_date, end_date = period.date_range.first, period.date_range.last
-
- return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
- return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
-
- days_apart = (end_date - start_date).to_i
-
- # Handle specific cases
- if start_date == Date.current.beginning_of_week && end_date == Date.current
- return "Current Week to Date (CWD)"
- elsif start_date == Date.current.beginning_of_month && end_date == Date.current
- return "Current Month to Date (MTD)"
- elsif start_date == Date.current.beginning_of_quarter && end_date == Date.current
- return "Current Quarter to Date (CQD)"
- elsif start_date == Date.current.beginning_of_year && end_date == Date.current
- return "Current Year to Date (YTD)"
- end
-
- # Default cases
- case days_apart
- when 1
- "vs. yesterday"
- when 7
- "vs. last week"
- when 30, 31
- "vs. last month"
- when 90
- "vs. last 3 months"
- when 365, 366
- "vs. last year"
- else
- "from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
- end
- end
-
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content
end
-
- def to_accountable_title(accountable)
- accountable.model_name.human
- end
-
- def accountable_text_class(accountable_type)
- class_mapping(accountable_type)[:text]
- end
-
- def accountable_fill_class(accountable_type)
- class_mapping(accountable_type)[:fill]
- end
-
- def accountable_bg_class(accountable_type)
- class_mapping(accountable_type)[:bg]
- end
-
- def accountable_bg_transparent_class(accountable_type)
- class_mapping(accountable_type)[:bg_transparent]
- end
-
- def accountable_color(accountable_type)
- class_mapping(accountable_type)[:hex]
- end
-
- def account_groups(period: nil)
- assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
- [ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
- end
-
- private
-
- def class_mapping(accountable_type)
- {
- "CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
- "Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
- "OtherLiability" => { text: "text-secondary", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
- "Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
- "Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
- "OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
- "Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
- "Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
- }.fetch(accountable_type, { text: "text-secondary", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
- end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 424ed539..e8d898cc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -72,18 +72,8 @@ module ApplicationHelper
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
end
- def sidebar_link_to(name, path, options = {})
- is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
-
- classes = [
- "flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-secondary",
- (is_current ? "bg-white text-primary shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
- ].compact.join(" ")
-
- link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
- concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
- concat(name)
- end
+ def page_active?(path)
+ current_page?(path) || (request.path.start_with?(path) && path != "/")
end
def mixed_hex_styles(hex)
@@ -105,24 +95,6 @@ module ApplicationHelper
uri.relative? ? uri.path : root_path
end
- def trend_styles(trend)
- fallback = { bg_class: "bg-gray-500/5", text_class: "text-secondary", symbol: "", icon: "minus" }
- return fallback if trend.nil? || trend.direction.flat?
-
- bg_class, text_class, symbol, icon = case trend.direction
- when "up"
- trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
- when "down"
- trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
- when "flat"
- [ "bg-gray-500/5", "text-secondary", "", "minus" ]
- else
- raise ArgumentError, "Invalid trend direction: #{trend.direction}"
- end
-
- { bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
- end
-
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
@@ -139,17 +111,7 @@ module ApplicationHelper
def format_money(number_or_money, options = {})
return nil unless number_or_money
- money = Money.new(number_or_money)
- options.reverse_merge!(money.format_options(I18n.locale))
- number_to_currency(money.amount, options)
- end
-
- def format_money_without_symbol(number_or_money, options = {})
- return nil unless number_or_money
-
- money = Money.new(number_or_money)
- options.reverse_merge!(money.format_options(I18n.locale))
- ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
+ Money.new(number_or_money).format(options)
end
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
@@ -168,7 +130,6 @@ module ApplicationHelper
end
private
-
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
total = items.sum(&money_method)
diff --git a/app/helpers/email_confirmations_helper.rb b/app/helpers/email_confirmations_helper.rb
deleted file mode 100644
index c5f58449..00000000
--- a/app/helpers/email_confirmations_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module EmailConfirmationsHelper
-end
diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb
index 39fa0740..22861eb0 100644
--- a/app/helpers/forms_helper.rb
+++ b/app/helpers/forms_helper.rb
@@ -18,18 +18,9 @@ module FormsHelper
end
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
- periods_for_select = [
- %w[CWD current_week], # Current Week to Date
- %w[7D last_7_days],
- %w[MTD current_month], # Month to Date
- %w[1M last_30_days],
- %w[CQD current_quarter], # Quarter to Date
- %w[3M last_90_days],
- %w[YTD current_year], # Year to Date
- %w[1Y last_365_days]
- ]
+ periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
- form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
+ form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
end
diff --git a/app/helpers/impersonation_sessions_helper.rb b/app/helpers/impersonation_sessions_helper.rb
deleted file mode 100644
index f955b896..00000000
--- a/app/helpers/impersonation_sessions_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module ImpersonationSessionsHelper
-end
diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb
deleted file mode 100644
index 1483b9ee..00000000
--- a/app/helpers/invitations_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module InvitationsHelper
-end
diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb
deleted file mode 100644
index 2c057fd0..00000000
--- a/app/helpers/pages_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module PagesHelper
-end
diff --git a/app/helpers/properties_helper.rb b/app/helpers/properties_helper.rb
deleted file mode 100644
index e9841903..00000000
--- a/app/helpers/properties_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module PropertiesHelper
-end
diff --git a/app/helpers/securities_helper.rb b/app/helpers/securities_helper.rb
deleted file mode 100644
index 05762cb3..00000000
--- a/app/helpers/securities_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module SecuritiesHelper
-end
diff --git a/app/helpers/settings/billing_helper.rb b/app/helpers/settings/billing_helper.rb
deleted file mode 100644
index 8de033a5..00000000
--- a/app/helpers/settings/billing_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module Settings::BillingHelper
-end
diff --git a/app/helpers/settings/hosting_helper.rb b/app/helpers/settings/hosting_helper.rb
deleted file mode 100644
index b92b592b..00000000
--- a/app/helpers/settings/hosting_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module Settings::HostingHelper
-end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 66488eff..0eaecd85 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -1,17 +1,17 @@
module SettingsHelper
SETTINGS_ORDER = [
- { name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path },
- { name: I18n.t("settings.nav.preferences_label"), path: :settings_preferences_path },
- { name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
- { name: I18n.t("settings.nav.security_label"), path: :settings_security_path },
- { name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
- { name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
- { name: I18n.t("settings.nav.imports_label"), path: :imports_path },
- { name: I18n.t("settings.nav.tags_label"), path: :tags_path },
- { name: I18n.t("settings.nav.categories_label"), path: :categories_path },
- { name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
- { name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
- { name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
+ { name: I18n.t("settings.settings_nav.profile_label"), path: :settings_profile_path },
+ { name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
+ { name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
+ { name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
+ { name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path },
+ { name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
+ { name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
+ { name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
+ { name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
+ { name: I18n.t("settings.settings_nav.merchants_label"), path: :merchants_path },
+ { name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path },
+ { name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
]
def adjacent_setting(current_path, offset)
@@ -24,7 +24,7 @@ module SettingsHelper
adjacent = visible_settings[adjacent_index]
- render partial: "settings/nav_link_large", locals: {
+ render partial: "settings/settings_nav_link_large", locals: {
path: send(adjacent[:path]),
direction: offset > 0 ? "next" : "previous",
title: adjacent[:name]
diff --git a/app/helpers/subscription_helper.rb b/app/helpers/subscription_helper.rb
deleted file mode 100644
index bac8d5a9..00000000
--- a/app/helpers/subscription_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module SubscriptionHelper
-end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
deleted file mode 100644
index 4a796243..00000000
--- a/app/helpers/tags_helper.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-module TagsHelper
- def null_tag
- Tag.new \
- name: "Uncategorized",
- color: Tag::UNCATEGORIZED_COLOR
- end
-end
diff --git a/app/helpers/value_groups_helper.rb b/app/helpers/value_groups_helper.rb
deleted file mode 100644
index 5362ed0c..00000000
--- a/app/helpers/value_groups_helper.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module ValueGroupsHelper
- def value_group_pie_data(value_group)
- value_group.children.filter { |c| c.sum > 0 }.map do |child|
- {
- label: to_accountable_title(Accountable.from_type(child.name)),
- percent_of_total: child.percent_of_total.round(1).to_f,
- formatted_value: format_money(child.sum, precision: 0),
- bg_color: accountable_bg_class(child.name),
- fill_color: accountable_fill_class(child.name)
- }
- end.to_json
- end
-end
diff --git a/app/helpers/vehicles_helper.rb b/app/helpers/vehicles_helper.rb
deleted file mode 100644
index f8e7abd9..00000000
--- a/app/helpers/vehicles_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module VehiclesHelper
-end
diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb
deleted file mode 100644
index 3fa66567..00000000
--- a/app/helpers/webhooks_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module WebhooksHelper
-end
diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js
index 22b0a59a..a1e40157 100644
--- a/app/javascript/controllers/bulk_select_controller.js
+++ b/app/javascript/controllers/bulk_select_controller.js
@@ -146,7 +146,9 @@ export default class extends Controller {
_updateGroups() {
this.groupTargets.forEach((group) => {
- const rows = this.rowTargets.filter((row) => group.contains(row));
+ const rows = this.rowTargets.filter(
+ (row) => group.contains(row) && !row.disabled,
+ );
const groupSelected =
rows.length > 0 &&
rows.every((row) => this.selectedIdsValue.includes(row.dataset.id));
diff --git a/app/javascript/controllers/pie_chart_controller.js b/app/javascript/controllers/pie_chart_controller.js
deleted file mode 100644
index 96cdc56f..00000000
--- a/app/javascript/controllers/pie_chart_controller.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-import * as d3 from "d3";
-
-// Connects to data-controller="pie-chart"
-export default class extends Controller {
- static values = {
- data: Array,
- total: String,
- label: String,
- };
-
- #d3SvgMemo = null;
- #d3GroupMemo = null;
- #d3ContentMemo = null;
- #d3ViewboxWidth = 200;
- #d3ViewboxHeight = 200;
-
- connect() {
- this.#draw();
- document.addEventListener("turbo:load", this.#redraw);
- }
-
- disconnect() {
- this.#teardown();
- document.removeEventListener("turbo:load", this.#redraw);
- }
-
- #redraw = () => {
- this.#teardown();
- this.#draw();
- };
-
- #teardown() {
- this.#d3SvgMemo = null;
- this.#d3GroupMemo = null;
- this.#d3ContentMemo = null;
- this.#d3Container.selectAll("*").remove();
- }
-
- #draw() {
- this.#d3Container.attr("class", "relative");
- this.#d3Content.html(this.#contentSummaryTemplate());
-
- const pie = d3
- .pie()
- .value((d) => d.percent_of_total)
- .padAngle(0.06);
-
- const arc = d3
- .arc()
- .innerRadius(this.#radius - 8)
- .outerRadius(this.#radius)
- .cornerRadius(2);
-
- const arcs = this.#d3Group
- .selectAll("arc")
- .data(pie(this.dataValue))
- .enter()
- .append("g")
- .attr("class", "arc");
-
- const paths = arcs
- .append("path")
- .attr("class", (d) => d.data.fill_color)
- .attr("d", arc);
-
- paths
- .on("mouseover", (event) => {
- this.#d3Svg.selectAll(".arc path").attr("class", "fill-gray-200");
- d3.select(event.target).attr("class", (d) => d.data.fill_color);
- this.#d3ContentMemo.html(
- this.#contentDetailTemplate(d3.select(event.target).datum().data),
- );
- })
- .on("mouseout", () => {
- this.#d3Svg
- .selectAll(".arc path")
- .attr("class", (d) => d.data.fill_color);
- this.#d3ContentMemo.html(this.#contentSummaryTemplate());
- });
- }
-
- #contentSummaryTemplate() {
- return `${this.totalValue} ${this.labelValue}`;
- }
-
- #contentDetailTemplate(datum) {
- return `
- ${datum.formatted_value}
-
-
-
${datum.label}
-
${datum.percent_of_total}%
-
- `;
- }
-
- get #radius() {
- return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
- }
-
- get #d3Container() {
- return d3.select(this.element);
- }
-
- get #d3Svg() {
- if (!this.#d3SvgMemo) {
- this.#d3SvgMemo = this.#createMainSvg();
- }
- return this.#d3SvgMemo;
- }
-
- get #d3Group() {
- if (!this.#d3GroupMemo) {
- this.#d3GroupMemo = this.#createMainGroup();
- }
-
- return this.#d3GroupMemo;
- }
-
- get #d3Content() {
- if (!this.#d3ContentMemo) {
- this.#d3ContentMemo = this.#createContent();
- }
- return this.#d3ContentMemo;
- }
-
- #createMainSvg() {
- return this.#d3Container
- .append("svg")
- .attr("width", "100%")
- .attr("class", "relative aspect-1")
- .attr("viewBox", [0, 0, this.#d3ViewboxWidth, this.#d3ViewboxHeight]);
- }
-
- #createMainGroup() {
- return this.#d3Svg
- .append("g")
- .attr(
- "transform",
- `translate(${this.#d3ViewboxWidth / 2},${this.#d3ViewboxHeight / 2})`,
- );
- }
-
- #createContent() {
- this.#d3ContentMemo = this.#d3Container
- .append("div")
- .attr(
- "class",
- "absolute inset-0 w-full text-center flex flex-col items-center justify-center",
- );
- return this.#d3ContentMemo;
- }
-}
diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js
new file mode 100644
index 00000000..7ed40418
--- /dev/null
+++ b/app/javascript/controllers/sidebar_controller.js
@@ -0,0 +1,28 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="sidebar"
+export default class extends Controller {
+ static values = { userId: String };
+ static targets = ["panel", "content"];
+
+ toggle() {
+ this.panelTarget.classList.toggle("w-0");
+ this.panelTarget.classList.toggle("opacity-0");
+ this.panelTarget.classList.toggle("w-[260px]");
+ this.panelTarget.classList.toggle("opacity-100");
+ this.contentTarget.classList.toggle("max-w-4xl");
+ this.contentTarget.classList.toggle("max-w-5xl");
+
+ fetch(`/users/${this.userIdValue}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
+ Accept: "application/json",
+ },
+ body: new URLSearchParams({
+ "user[show_sidebar]": !this.panelTarget.classList.contains("w-0"),
+ }).toString(),
+ });
+ }
+}
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
index 7fb3e0c5..1e1cd614 100644
--- a/app/javascript/controllers/tabs_controller.js
+++ b/app/javascript/controllers/tabs_controller.js
@@ -2,12 +2,16 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="tabs"
export default class extends Controller {
- static classes = ["active"];
+ static classes = ["active", "inactive"];
static targets = ["btn", "tab"];
- static values = { defaultTab: String };
+ static values = { defaultTab: String, localStorageKey: String };
connect() {
- this.updateClasses(this.defaultTabValue);
+ const selectedTab = this.hasLocalStorageKeyValue
+ ? this.getStoredTab() || this.defaultTabValue
+ : this.defaultTabValue;
+
+ this.updateClasses(selectedTab);
document.addEventListener("turbo:load", this.onTurboLoad);
}
@@ -18,23 +22,46 @@ export default class extends Controller {
select(event) {
const element = event.target.closest("[data-id]");
if (element) {
- this.updateClasses(element.dataset.id);
+ const selectedId = element.dataset.id;
+ this.updateClasses(selectedId);
+ if (this.hasLocalStorageKeyValue) {
+ this.storeTab(selectedId);
+ }
}
}
onTurboLoad = () => {
- this.updateClasses(this.defaultTabValue);
+ const selectedTab = this.hasLocalStorageKeyValue
+ ? this.getStoredTab() || this.defaultTabValue
+ : this.defaultTabValue;
+
+ this.updateClasses(selectedTab);
};
+ getStoredTab() {
+ const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
+ return tabs[this.localStorageKeyValue];
+ }
+
+ storeTab(selectedId) {
+ const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
+ tabs[this.localStorageKeyValue] = selectedId;
+ localStorage.setItem("tabs", JSON.stringify(tabs));
+ }
+
updateClasses = (selectedId) => {
- this.btnTargets.forEach((btn) =>
- btn.classList.remove(...this.activeClasses),
- );
+ this.btnTargets.forEach((btn) => {
+ btn.classList.remove(...this.activeClasses);
+ btn.classList.remove(...this.inactiveClasses);
+ });
+
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));
this.btnTargets.forEach((btn) => {
if (btn.dataset.id === selectedId) {
btn.classList.add(...this.activeClasses);
+ } else {
+ btn.classList.add(...this.inactiveClasses);
}
});
diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js
index 4ae37bfa..d3cc05cc 100644
--- a/app/javascript/controllers/time_series_chart_controller.js
+++ b/app/javascript/controllers/time_series_chart_controller.js
@@ -7,7 +7,6 @@ export default class extends Controller {
strokeWidth: { type: Number, default: 2 },
useLabels: { type: Boolean, default: true },
useTooltip: { type: Boolean, default: true },
- usePercentSign: Boolean,
};
_d3SvgMemo = null;
@@ -16,15 +15,18 @@ export default class extends Controller {
_d3InitialContainerWidth = 0;
_d3InitialContainerHeight = 0;
_normalDataPoints = [];
+ _resizeObserver = null;
connect() {
this._install();
document.addEventListener("turbo:load", this._reinstall);
+ this._setupResizeObserver();
}
disconnect() {
this._teardown();
document.removeEventListener("turbo:load", this._reinstall);
+ this._resizeObserver?.disconnect();
}
_reinstall = () => {
@@ -49,10 +51,9 @@ export default class extends Controller {
_normalizeDataPoints() {
this._normalDataPoints = (this.dataValue.values || []).map((d) => ({
- ...d,
date: new Date(`${d.date}T00:00:00Z`),
- value: d.value.amount ? +d.value.amount : +d.value,
- currency: d.value.currency,
+ date_formatted: d.date_formatted,
+ trend: d.trend,
}));
}
@@ -138,13 +139,13 @@ export default class extends Controller {
.append("stop")
.attr("class", "start-color")
.attr("offset", "0%")
- .attr("stop-color", this._trendColor);
+ .attr("stop-color", this.dataValue.trend.color);
gradient
.append("stop")
.attr("class", "middle-color")
.attr("offset", "100%")
- .attr("stop-color", this._trendColor);
+ .attr("stop-color", this.dataValue.trend.color);
gradient
.append("stop")
@@ -182,7 +183,7 @@ export default class extends Controller {
this._normalDataPoints[this._normalDataPoints.length - 1].date,
])
.tickSize(0)
- .tickFormat(d3.utcFormat("%d %b %Y")),
+ .tickFormat(d3.utcFormat("%b %d, %Y")),
)
.select(".domain")
.remove();
@@ -212,7 +213,7 @@ export default class extends Controller {
.attr("x2", 0)
.attr(
"y1",
- this._d3YScale(d3.max(this._normalDataPoints, (d) => d.value)),
+ this._d3YScale(d3.max(this._normalDataPoints, this._getDatumValue)),
)
.attr("y2", this._d3ContainerHeight);
@@ -240,7 +241,7 @@ export default class extends Controller {
.area()
.x((d) => this._d3XScale(d.date))
.y0(this._d3ContainerHeight)
- .y1((d) => this._d3YScale(d.value)),
+ .y1((d) => this._d3YScale(this._getDatumValue(d))),
);
// Apply the gradient + clip path
@@ -320,7 +321,7 @@ export default class extends Controller {
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this._d3XScale(d.date))
- .attr("cy", this._d3YScale(d.value))
+ .attr("cy", this._d3YScale(this._getDatumValue(d)))
.attr("r", 8)
.attr("fill", this._trendColor)
.attr("fill-opacity", "0.1")
@@ -331,7 +332,7 @@ export default class extends Controller {
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this._d3XScale(d.date))
- .attr("cy", this._d3YScale(d.value))
+ .attr("cy", this._d3YScale(this._getDatumValue(d)))
.attr("r", 3)
.attr("fill", this._trendColor)
.attr("pointer-events", "none");
@@ -361,7 +362,7 @@ export default class extends Controller {
_tooltipTemplate(datum) {
return `
- ${d3.utcFormat("%b %d, %Y")(datum.date)}
+ ${datum.date_formatted}
@@ -371,24 +372,20 @@ export default class extends Controller {
cx="5"
cy="5"
r="4"
- stroke="${this._tooltipTrendColor(datum)}"
+ stroke="${datum.trend.color}"
fill="transparent"
stroke-width="1">
- ${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
+ ${this._extractFormattedValue(datum.trend.current)}
${
- this.usePercentSignValue ||
- datum.trend.value === 0 ||
- datum.trend.value.amount === 0
- ? `
-
- `
+ datum.trend.value === 0
+ ? ``
: `
-
- ${this._tooltipChange(datum)} (${datum.trend.percent}%)
+
+ ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
`
}
@@ -396,55 +393,23 @@ export default class extends Controller {
`;
}
- _tooltipTrendColor(datum) {
- return {
- up:
- datum.trend.favorable_direction === "up"
- ? "var(--color-success)"
- : "var(--color-destructive)",
- down:
- datum.trend.favorable_direction === "down"
- ? "var(--color-success)"
- : "var(--color-destructive)",
- flat: "var(--color-gray-500)",
- }[datum.trend.direction];
- }
+ _getDatumValue = (datum) => {
+ return this._extractNumericValue(datum.trend.current);
+ };
- _tooltipValue(datum) {
- if (datum.currency) {
- return this._currencyValue(datum);
+ _extractNumericValue = (numeric) => {
+ if (typeof numeric === "object" && "amount" in numeric) {
+ return Number(numeric.amount);
}
- return datum.value;
- }
+ return Number(numeric);
+ };
- _tooltipChange(datum) {
- if (datum.currency) {
- return this._currencyChange(datum);
+ _extractFormattedValue = (numeric) => {
+ if (typeof numeric === "object" && "formatted" in numeric) {
+ return numeric.formatted;
}
- return this._decimalChange(datum);
- }
-
- _currencyValue(datum) {
- return Intl.NumberFormat(undefined, {
- style: "currency",
- currency: datum.currency,
- }).format(datum.value);
- }
-
- _currencyChange(datum) {
- return Intl.NumberFormat(undefined, {
- style: "currency",
- currency: datum.currency,
- signDisplay: "always",
- }).format(datum.trend.value.amount);
- }
-
- _decimalChange(datum) {
- return Intl.NumberFormat(undefined, {
- style: "decimal",
- signDisplay: "always",
- }).format(datum.trend.value);
- }
+ return numeric;
+ };
_createMainSvg() {
return this._d3Container
@@ -503,28 +468,14 @@ export default class extends Controller {
}
get _trendColor() {
- if (this._trendDirection === "flat") {
- return "var(--color-gray-500)";
- }
- if (this._trendDirection === this._favorableDirection) {
- return "var(--color-green-500)";
- }
- return "var(--color-destructive)";
- }
-
- get _trendDirection() {
- return this.dataValue.trend.direction;
- }
-
- get _favorableDirection() {
- return this.dataValue.trend.favorable_direction;
+ return this.dataValue.trend.color;
}
get _d3Line() {
return d3
.line()
.x((d) => this._d3XScale(d.date))
- .y((d) => this._d3YScale(d.value));
+ .y((d) => this._d3YScale(this._getDatumValue(d)));
}
get _d3XScale() {
@@ -536,8 +487,8 @@ export default class extends Controller {
get _d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
- const dataMin = d3.min(this._normalDataPoints, (d) => d.value);
- const dataMax = d3.max(this._normalDataPoints, (d) => d.value);
+ const dataMin = d3.min(this._normalDataPoints, this._getDatumValue);
+ const dataMax = d3.max(this._normalDataPoints, this._getDatumValue);
const padding = (dataMax - dataMin) * reductionPercent;
return d3
@@ -545,4 +496,11 @@ export default class extends Controller {
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
}
+
+ _setupResizeObserver() {
+ this._resizeObserver = new ResizeObserver(() => {
+ this._reinstall();
+ });
+ this._resizeObserver.observe(this.element);
+ }
}
diff --git a/app/models/account.rb b/app/models/account.rb
index 7b86a88e..75752077 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
- include Syncable, Monetizable, Issuable
+ include Syncable, Monetizable, Issuable, Chartable
validates :name, :balance, :currency, presence: true
@@ -20,7 +20,7 @@ class Account < ApplicationRecord
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
- scope :active, -> { where(is_active: true, scheduled_for_deletion: false) }
+ scope :active, -> { where(is_active: true) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
@@ -32,34 +32,7 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true
- def institution_domain
- return nil unless plaid_account&.plaid_item&.institution_url.present?
- URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
- end
-
class << self
- def by_group(period: Period.all, currency: Money.default_currency.iso_code)
- grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
-
- Accountable.by_classification.each do |classification, types|
- types.each do |type|
- accounts = self.where(accountable_type: type)
- if accounts.any?
- group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
- accounts.each do |account|
- group.add_value_node(
- account,
- account.balance_money.exchange_to(currency, fallback_rate: 0),
- account.series(period: period, currency: currency)
- )
- end
- end
- end
- end
-
- grouped_accounts
- end
-
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
@@ -89,8 +62,13 @@ class Account < ApplicationRecord
end
end
+ def institution_domain
+ return nil unless plaid_account&.plaid_item&.institution_url.present?
+ URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
+ end
+
def destroy_later
- update!(scheduled_for_deletion: true)
+ update!(scheduled_for_deletion: true, is_active: false)
DestroyJob.perform_later(self)
end
@@ -106,18 +84,6 @@ class Account < ApplicationRecord
accountable.post_sync
end
- def series(period: Period.last_30_days, currency: nil)
- balance_series = balances.in_period(period).where(currency: currency || self.currency)
-
- if balance_series.empty? && period.date_range.end == Date.current
- TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ])
- else
- TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down")
- end
- rescue Money::ConversionError
- TimeSeries.new([])
- end
-
def original_balance
balance_amount = balances.chronological.first&.balance || balance
Money.new(balance_amount, currency)
@@ -127,10 +93,6 @@ class Account < ApplicationRecord
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
- def favorable_direction
- classification == "asset" ? "up" : "down"
- end
-
def enrich_data
DataEnricher.new(self).run
end
@@ -161,63 +123,11 @@ class Account < ApplicationRecord
end
end
- def transfer_match_candidates
- Account::Entry.select([
- "inflow_candidates.entryable_id as inflow_transaction_id",
- "outflow_candidates.entryable_id as outflow_transaction_id",
- "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
- ]).from("account_entries inflow_candidates")
- .joins("
- JOIN account_entries outflow_candidates ON (
- inflow_candidates.amount < 0 AND
- outflow_candidates.amount > 0 AND
- inflow_candidates.amount = -outflow_candidates.amount AND
- inflow_candidates.currency = outflow_candidates.currency AND
- inflow_candidates.account_id <> outflow_candidates.account_id AND
- inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
- )
- ").joins("
- LEFT JOIN transfers existing_transfers ON (
- existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
- existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
- )
- ")
- .joins("LEFT JOIN rejected_transfers ON (
- rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
- rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
- )")
- .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
- .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
- .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.family_id, self.family_id)
- .where("inflow_accounts.is_active = true AND inflow_accounts.scheduled_for_deletion = false")
- .where("outflow_accounts.is_active = true AND outflow_accounts.scheduled_for_deletion = false")
- .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
- .where(existing_transfers: { id: nil })
- .order("date_diff ASC") # Closest matches first
- end
+ def sparkline_series
+ cache_key = family.build_cache_key("#{id}_sparkline")
- def auto_match_transfers!
- # Exclude already matched transfers
- candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
-
- # Track which transactions we've already matched to avoid duplicates
- used_transaction_ids = Set.new
-
- candidates = []
-
- Transfer.transaction do
- candidates_scope.each do |match|
- next if used_transaction_ids.include?(match.inflow_transaction_id) ||
- used_transaction_ids.include?(match.outflow_transaction_id)
-
- Transfer.create!(
- inflow_transaction_id: match.inflow_transaction_id,
- outflow_transaction_id: match.outflow_transaction_id,
- )
-
- used_transaction_ids << match.inflow_transaction_id
- used_transaction_ids << match.outflow_transaction_id
- end
+ Rails.cache.fetch(cache_key) do
+ balance_series
end
end
end
diff --git a/app/models/account/balance.rb b/app/models/account/balance.rb
index cd361269..5d4e3710 100644
--- a/app/models/account/balance.rb
+++ b/app/models/account/balance.rb
@@ -4,6 +4,6 @@ class Account::Balance < ApplicationRecord
belongs_to :account
validates :account, :date, :balance, presence: true
monetize :balance
- scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
+ scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end
diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/account/balance_trend_calculator.rb
index a2e89e1d..a9bfeb30 100644
--- a/app/models/account/balance_trend_calculator.rb
+++ b/app/models/account/balance_trend_calculator.rb
@@ -80,7 +80,7 @@ class Account::BalanceTrendCalculator
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
BalanceTrend.new(
- trend: TimeSeries::Trend.new(
+ trend: Trend.new(
current: Money.new(intraday_balance, entry.currency),
previous: Money.new(prior_balance, entry.currency),
favorable_direction: entry.account.favorable_direction
diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb
new file mode 100644
index 00000000..aba8415c
--- /dev/null
+++ b/app/models/account/chartable.rb
@@ -0,0 +1,102 @@
+module Account::Chartable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
+ balances = Account::Balance.find_by_sql([
+ balance_series_query,
+ {
+ start_date: period.start_date,
+ end_date: period.end_date,
+ interval: period.interval,
+ target_currency: currency
+ }
+ ])
+
+ balances = gapfill_balances(balances)
+
+ values = [ nil, *balances ].each_cons(2).map do |prev, curr|
+ Series::Value.new(
+ date: curr.date,
+ date_formatted: I18n.l(curr.date, format: :long),
+ trend: Trend.new(
+ current: Money.new(curr.balance, currency),
+ previous: prev.nil? ? nil : Money.new(prev.balance, currency),
+ favorable_direction: favorable_direction
+ )
+ )
+ end
+
+ Series.new(
+ start_date: period.start_date,
+ end_date: period.end_date,
+ interval: period.interval,
+ trend: Trend.new(
+ current: Money.new(balances.last&.balance || 0, currency),
+ previous: Money.new(balances.first&.balance || 0, currency),
+ favorable_direction: favorable_direction
+ ),
+ values: values
+ )
+ end
+
+ private
+ def balance_series_query
+ <<~SQL
+ WITH dates as (
+ SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date as date
+ UNION DISTINCT
+ SELECT CURRENT_DATE -- Ensures we always end on current date, regardless of interval
+ )
+ SELECT
+ d.date,
+ SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
+ 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 (
+ ab.date = d.date AND
+ ab.currency = accounts.currency AND
+ ab.account_id = accounts.id
+ )
+ LEFT JOIN exchange_rates er ON (
+ er.date = ab.date AND
+ er.from_currency = accounts.currency AND
+ er.to_currency = :target_currency
+ )
+ GROUP BY d.date
+ ORDER BY d.date
+ SQL
+ end
+
+ def gapfill_balances(balances)
+ gapfilled = []
+
+ prev_balance = nil
+
+ [ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
+ if index == 0 && curr.balance.nil?
+ curr.balance = 0 # Ensure all series start with a non-nil balance
+ elsif curr.balance.nil?
+ curr.balance = prev.balance
+ end
+
+ gapfilled << curr
+ end
+
+ gapfilled
+ end
+ end
+
+ def favorable_direction
+ classification == "asset" ? "up" : "down"
+ end
+
+ def balance_series(period: Period.last_30_days)
+ self.class.where(id: self.id).balance_series(
+ currency: currency,
+ period: period,
+ favorable_direction: favorable_direction
+ )
+ end
+end
diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb
index 8ec0175b..b53db19b 100644
--- a/app/models/account/entry.rb
+++ b/app/models/account/entry.rb
@@ -1,8 +1,6 @@
class Account::Entry < ApplicationRecord
include Monetizable
- Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true)
-
monetize :amount
belongs_to :account
@@ -16,6 +14,10 @@ class Account::Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
+ scope :active, -> {
+ joins(:account).where(accounts: { is_active: true })
+ }
+
scope :chronological, -> {
order(
date: :asc,
@@ -24,10 +26,6 @@ class Account::Entry < ApplicationRecord
)
}
- scope :active, -> {
- joins(:account).where(accounts: { is_active: true, scheduled_for_deletion: false })
- }
-
scope :reverse_chronological, -> {
order(
date: :desc,
@@ -36,35 +34,6 @@ class Account::Entry < ApplicationRecord
)
}
- # All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
- scope :incomes_and_expenses, -> {
- joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
- .joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
- .joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
- .joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
- .joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
- .where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
- }
-
- scope :incomes, -> {
- incomes_and_expenses.where("account_entries.amount <= 0")
- }
-
- scope :expenses, -> {
- incomes_and_expenses.where("account_entries.amount > 0")
- }
-
- scope :with_converted_amount, ->(currency) {
- # Join with exchange rates to convert the amount to the given currency
- # If no rate is available, exclude the transaction from the results
- select(
- "account_entries.*",
- "account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
- )
- .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
- .where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
- }
-
def sync_account_later
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(start_date: sync_start_date)
@@ -82,23 +51,6 @@ class Account::Entry < ApplicationRecord
enriched_name.presence || name
end
- def transfer_match_candidates
- candidates_scope = account.transfer_match_candidates
-
- candidates_scope = if amount.negative?
- candidates_scope.where("inflow_candidates.entryable_id = ?", entryable_id)
- else
- candidates_scope.where("outflow_candidates.entryable_id = ?", entryable_id)
- end
-
- candidates_scope.map do |pm|
- Transfer.new(
- inflow_transaction_id: pm.inflow_transaction_id,
- outflow_transaction_id: pm.outflow_transaction_id,
- )
- end
- end
-
class << self
def search(params)
Account::EntrySearch.new(params).build_query(all)
@@ -109,35 +61,6 @@ class Account::Entry < ApplicationRecord
30.years.ago.to_date
end
- def daily_totals(entries, currency, period: Period.last_30_days)
- # Sum spending and income for each day in the period with the given currency
- select(
- "gs.date",
- "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
- "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
- )
- .from(entries.with_converted_amount(currency), :e)
- .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
- .group("gs.date")
- end
-
- def daily_rolling_totals(entries, currency, period: Period.last_30_days)
- # Extend the period to include the rolling window
- period_with_rolling = period.extend_backward(period.date_range.count.days)
-
- # Aggregate the rolling sum of spending and income based on daily totals
- rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
- .select(
- "*",
- sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
- sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
- )
- .order(:date)
-
- # Trim the results to the original period
- select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
- end
-
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
@@ -159,25 +82,5 @@ class Account::Entry < ApplicationRecord
all.size
end
-
- def stats(currency = "USD")
- result = all
- .incomes_and_expenses
- .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
- .select(
- "COUNT(*) AS count",
- "SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total",
- "SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total"
- )
- .to_a
- .first
-
- Stats.new(
- currency: currency,
- count: result.count,
- income_total: result.income_total ? result.income_total * -1 : 0,
- expense_total: result.expense_total || 0
- )
- end
end
end
diff --git a/app/models/account/entry_search.rb b/app/models/account/entry_search.rb
index 962cb5c0..b6617037 100644
--- a/app/models/account/entry_search.rb
+++ b/app/models/account/entry_search.rb
@@ -12,29 +12,44 @@ class Account::EntrySearch
attribute :end_date, :string
class << self
- def from_entryable_search(entryable_search)
- new(entryable_search.attributes.slice(*attribute_names))
+ def apply_search_filter(scope, search)
+ return scope if search.blank?
+
+ query = scope
+ query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
+ search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
+ )
+ query
end
- end
- def build_query(scope)
- query = scope
+ def apply_date_filters(scope, start_date, end_date)
+ return scope if start_date.blank? && end_date.blank?
- query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
- search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
- ) if search.present?
- query = query.where("account_entries.date >= ?", start_date) if start_date.present?
- query = query.where("account_entries.date <= ?", end_date) if end_date.present?
+ 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
+ end
+
+ def apply_type_filter(scope, types)
+ return scope if types.blank?
+
+ query = scope
- if types.present?
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
+
+ query
end
- if amount.present? && amount_operator.present?
+ def apply_amount_filter(scope, amount, amount_operator)
+ return scope if amount.blank? || amount_operator.blank?
+
+ query = scope
+
case amount_operator
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
@@ -43,15 +58,27 @@ class Account::EntrySearch
when "greater"
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
end
+
+ query
end
- if accounts.present? || account_ids.present?
- query = query.joins(:account)
+ def apply_accounts_filter(scope, accounts, account_ids)
+ return scope if accounts.blank? && account_ids.blank?
+
+ query = scope
+ query = query.where(accounts: { name: accounts }) if accounts.present?
+ query = query.where(accounts: { id: account_ids }) if account_ids.present?
+ query
end
+ end
- query = query.where(accounts: { name: accounts }) if accounts.present?
- query = query.where(accounts: { id: account_ids }) if account_ids.present?
-
+ def build_query(scope)
+ query = scope.joins(:account)
+ query = self.class.apply_search_filter(query, search)
+ query = self.class.apply_date_filters(query, start_date, end_date)
+ query = self.class.apply_type_filter(query, types)
+ query = self.class.apply_amount_filter(query, amount, amount_operator)
+ query = self.class.apply_accounts_filter(query, accounts, account_ids)
query
end
end
diff --git a/app/models/account/entryable.rb b/app/models/account/entryable.rb
index 79a47849..91df5521 100644
--- a/app/models/account/entryable.rb
+++ b/app/models/account/entryable.rb
@@ -9,5 +9,21 @@ module Account::Entryable
included do
has_one :entry, as: :entryable, touch: true
+
+ scope :with_entry, -> { joins(:entry) }
+
+ scope :active, -> { with_entry.merge(Account::Entry.active) }
+
+ scope :in_period, ->(period) {
+ with_entry.where(account_entries: { date: period.start_date..period.end_date })
+ }
+
+ scope :reverse_chronological, -> {
+ with_entry.merge(Account::Entry.reverse_chronological)
+ }
+
+ scope :chronological, -> {
+ with_entry.merge(Account::Entry.chronological)
+ }
end
end
diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb
index 3b6ce472..eb6e35ef 100644
--- a/app/models/account/holding.rb
+++ b/app/models/account/holding.rb
@@ -53,13 +53,12 @@ class Account::Holding < ApplicationRecord
end
private
-
def calculate_trend
return nil unless amount_money
start_amount = qty * avg_cost
- TimeSeries::Trend.new \
+ Trend.new \
current: amount_money,
previous: start_amount
end
diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb
index e8bf7a40..8aeb0ba0 100644
--- a/app/models/account/syncer.rb
+++ b/app/models/account/syncer.rb
@@ -5,7 +5,7 @@ class Account::Syncer
end
def run
- account.auto_match_transfers!
+ account.family.auto_match_transfers!
holdings = sync_holdings
balances = sync_balances(holdings)
diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb
index 7d4976ba..a683b2ca 100644
--- a/app/models/account/trade.rb
+++ b/app/models/account/trade.rb
@@ -16,6 +16,6 @@ class Account::Trade < ApplicationRecord
current_value = current_price * qty.abs
cost_basis = price_money * qty.abs
- TimeSeries::Trend.new(current: current_value, previous: cost_basis)
+ Trend.new(current: current_value, previous: cost_basis)
end
end
diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb
index a3a91bf9..a500ef74 100644
--- a/app/models/account/transaction.rb
+++ b/app/models/account/transaction.rb
@@ -1,33 +1,17 @@
class Account::Transaction < ApplicationRecord
- include Account::Entryable
+ include Account::Entryable, Transferable
belongs_to :category, optional: true
belongs_to :merchant, optional: true
+
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
- has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
- has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
-
- # We keep track of rejected transfers to avoid auto-matching them again
- has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
- has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
-
accepts_nested_attributes_for :taggings, allow_destroy: true
- scope :active, -> { where(excluded: false) }
-
class << self
def search(params)
Account::TransactionSearch.new(params).build_query(all)
end
end
-
- def transfer
- transfer_as_inflow || transfer_as_outflow
- end
-
- def transfer?
- transfer.present?
- end
end
diff --git a/app/models/account/transaction/transferable.rb b/app/models/account/transaction/transferable.rb
new file mode 100644
index 00000000..de0b70cb
--- /dev/null
+++ b/app/models/account/transaction/transferable.rb
@@ -0,0 +1,40 @@
+module Account::Transaction::Transferable
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
+ has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
+
+ # We keep track of rejected transfers to avoid auto-matching them again
+ has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
+ has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
+ end
+
+ def transfer
+ transfer_as_inflow || transfer_as_outflow
+ end
+
+ def transfer?
+ transfer.present?
+ end
+
+ def transfer_match_candidates
+ candidates_scope = if self.entry.amount.negative?
+ family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
+ else
+ family_matches_scope.where("outflow_candidates.entryable_id = ?", self.id)
+ end
+
+ candidates_scope.map do |match|
+ Transfer.new(
+ inflow_transaction_id: match.inflow_transaction_id,
+ outflow_transaction_id: match.outflow_transaction_id,
+ )
+ end
+ end
+
+ private
+ def family_matches_scope
+ self.entry.account.family.transfer_match_candidates
+ end
+end
diff --git a/app/models/account/transaction_search.rb b/app/models/account/transaction_search.rb
index 12884302..3cf927e8 100644
--- a/app/models/account/transaction_search.rb
+++ b/app/models/account/transaction_search.rb
@@ -16,7 +16,7 @@ class Account::TransactionSearch
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
- query = scope
+ query = scope.joins(entry: :account)
if types.present? && types.exclude?("transfer")
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
@@ -40,8 +40,13 @@ class Account::TransactionSearch
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
- entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
+ # Apply common entry search filters
+ query = Account::EntrySearch.apply_search_filter(query, search)
+ query = Account::EntrySearch.apply_date_filters(query, start_date, end_date)
+ query = Account::EntrySearch.apply_type_filter(query, types)
+ query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator)
+ query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids)
- Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
+ query
end
end
diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb
new file mode 100644
index 00000000..f6b07f16
--- /dev/null
+++ b/app/models/balance_sheet.rb
@@ -0,0 +1,95 @@
+class BalanceSheet
+ include Monetizable
+
+ monetize :total_assets, :total_liabilities, :net_worth
+
+ attr_reader :family
+
+ def initialize(family)
+ @family = family
+ end
+
+ def total_assets
+ totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
+ end
+
+ def total_liabilities
+ totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
+ end
+
+ def net_worth
+ total_assets - total_liabilities
+ end
+
+ def classification_groups
+ [
+ ClassificationGroup.new(
+ key: "asset",
+ display_name: "Assets",
+ icon: "blocks",
+ account_groups: account_groups("asset")
+ ),
+ ClassificationGroup.new(
+ key: "liability",
+ display_name: "Debts",
+ icon: "scale",
+ account_groups: account_groups("liability")
+ )
+ ]
+ end
+
+ def account_groups(classification = nil)
+ classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
+ classification_total = classification_accounts.sum(&:converted_balance)
+ account_groups = classification_accounts.group_by(&:accountable_type).transform_keys { |k| Accountable.from_type(k) }
+
+ account_groups.map do |accountable, accounts|
+ group_total = accounts.sum(&:converted_balance)
+
+ AccountGroup.new(
+ key: accountable.model_name.param_key,
+ name: accountable.display_name,
+ classification: accountable.classification,
+ total: group_total,
+ total_money: Money.new(group_total, currency),
+ weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
+ missing_rates?: accounts.any? { |a| a.missing_rates? },
+ color: accountable.color,
+ accounts: accounts.map do |account|
+ account.define_singleton_method(:weight) do
+ classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
+ end
+ account
+ end.sort_by(&:weight).reverse
+ )
+ end.sort_by(&:weight).reverse
+ end
+
+ def net_worth_series(period: Period.last_30_days)
+ active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up")
+ end
+
+ def currency
+ family.currency
+ end
+
+ private
+ ClassificationGroup = Struct.new(:key, :display_name, :icon, :account_groups, keyword_init: true)
+ AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true)
+
+ def active_accounts
+ family.accounts.active.with_attached_logo
+ end
+
+ def totals_query
+ @totals_query ||= active_accounts
+ .joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
+ .select(
+ "accounts.*",
+ "SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
+ ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
+ )
+ .group(:classification, :accountable_type, :id)
+ .to_a
+ end
+end
diff --git a/app/models/budget.rb b/app/models/budget.rb
index 134258ca..0dfb16e8 100644
--- a/app/models/budget.rb
+++ b/app/models/budget.rb
@@ -1,9 +1,11 @@
class Budget < ApplicationRecord
include Monetizable
+ PARAM_DATE_FORMAT = "%b-%Y"
+
belongs_to :family
- has_many :budget_categories, dependent: :destroy
+ has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
@@ -13,16 +15,28 @@ class Budget < ApplicationRecord
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
class << self
- def for_date(date)
- find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
+ def date_to_param(date)
+ date.strftime(PARAM_DATE_FORMAT).downcase
end
- def find_or_bootstrap(family, date: Date.current)
+ def param_to_date(param)
+ Date.strptime(param, PARAM_DATE_FORMAT).beginning_of_month
+ end
+
+ def budget_date_valid?(date, family:)
+ beginning_of_month = date.beginning_of_month
+
+ beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month
+ end
+
+ def find_or_bootstrap(family, start_date:)
+ return nil unless budget_date_valid?(start_date, family: family)
+
Budget.transaction do
budget = Budget.find_or_create_by!(
family: family,
- start_date: date.beginning_of_month,
- end_date: date.end_of_month
+ start_date: start_date.beginning_of_month,
+ end_date: start_date.end_of_month
) do |b|
b.currency = family.currency
end
@@ -32,17 +46,38 @@ class Budget < ApplicationRecord
budget
end
end
+
+ private
+ def oldest_valid_budget_date(family)
+ @oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
+ end
+ end
+
+ def period
+ Period.new(start_date: start_date, end_date: end_date)
+ end
+
+ def to_param
+ self.class.date_to_param(start_date)
end
def sync_budget_categories
- family.categories.expenses.each do |category|
- budget_categories.find_or_create_by(
- category: category,
- ) do |bc|
- bc.budgeted_spending = 0
- bc.currency = family.currency
- end
+ current_category_ids = family.categories.expenses.pluck(:id).to_set
+ existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
+ categories_to_add = current_category_ids - existing_budget_category_ids
+ categories_to_remove = existing_budget_category_ids - current_category_ids
+
+ # Create missing categories
+ categories_to_add.each do |category_id|
+ budget_categories.create!(
+ category_id: category_id,
+ budgeted_spending: 0,
+ currency: family.currency
+ )
end
+
+ # Remove old categories
+ budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?
end
def uncategorized_budget_category
@@ -52,8 +87,8 @@ class Budget < ApplicationRecord
end
end
- def entries
- family.entries.incomes_and_expenses.where(date: start_date..end_date)
+ def transactions
+ family.transactions.active.in_period(period)
end
def name
@@ -64,28 +99,32 @@ class Budget < ApplicationRecord
budgeted_spending.present?
end
- def income_categories_with_totals
- family.income_categories_with_totals(date: start_date)
+ def income_category_totals
+ income_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
end
- def expense_categories_with_totals
- family.expense_categories_with_totals(date: start_date)
+ def expense_category_totals
+ expense_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
- def previous_budget
- prev_month_end_date = end_date - 1.month
- return nil if prev_month_end_date < family.oldest_entry_date
- family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
+ def previous_budget_param
+ previous_date = start_date - 1.month
+ return nil unless self.class.budget_date_valid?(previous_date, family: family)
+
+ self.class.date_to_param(previous_date)
end
- def next_budget
+ def next_budget_param
return nil if current?
- next_start_date = start_date + 1.month
- family.budgets.find_or_bootstrap(family, date: next_start_date)
+
+ next_date = start_date + 1.month
+ return nil unless self.class.budget_date_valid?(next_date, family: family)
+
+ self.class.date_to_param(next_date)
end
def to_donut_segments_json
@@ -94,8 +133,8 @@ class Budget < ApplicationRecord
# Continuous gray segment for empty budgets
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
- segments = budget_categories.includes(:category).map do |bc|
- { color: bc.category.color, amount: bc.actual_spending, id: bc.id }
+ segments = budget_categories.map do |bc|
+ { color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
end
if available_to_spend.positive?
@@ -109,11 +148,23 @@ class Budget < ApplicationRecord
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
- family.budgeting_stats.avg_monthly_expenses&.abs
+ income_statement.median_expense(interval: "month")
end
def actual_spending
- expense_categories_with_totals.total_money.amount
+ expense_totals.total
+ end
+
+ def budget_category_actual_spending(budget_category)
+ expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0
+ end
+
+ def category_median_monthly_expense(category)
+ income_statement.median_expense(category: category)
+ end
+
+ def category_avg_monthly_expense(category)
+ income_statement.avg_expense(category: category)
end
def available_to_spend
@@ -157,11 +208,11 @@ class Budget < ApplicationRecord
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
- family.budgeting_stats.avg_monthly_income&.abs
+ family.income_statement.median_income(interval: "month")
end
def actual_income
- family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
+ family.income_statement.income_totals(period: self.period).total
end
def actual_income_percent
@@ -179,4 +230,17 @@ class Budget < ApplicationRecord
remaining_expected_income.abs / expected_income.to_f * 100
end
+
+ private
+ def income_statement
+ @income_statement ||= family.income_statement
+ end
+
+ def expense_totals
+ @expense_totals ||= income_statement.expense_totals(period: period)
+ end
+
+ def income_totals
+ @income_totals ||= family.income_statement.income_totals(period: period)
+ end
end
diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb
index 5109198d..e909a7e8 100644
--- a/app/models/budget_category.rb
+++ b/app/models/budget_category.rb
@@ -6,7 +6,7 @@ class BudgetCategory < ApplicationRecord
validates :budget_id, uniqueness: { scope: :category_id }
- monetize :budgeted_spending, :actual_spending, :available_to_spend
+ monetize :budgeted_spending, :available_to_spend, :avg_monthly_expense, :median_monthly_expense, :actual_spending
class Group
attr_reader :budget_category, :budget_subcategories
@@ -45,12 +45,24 @@ class BudgetCategory < ApplicationRecord
super || budget.family.categories.uncategorized
end
- def subcategory?
- category.parent_id.present?
+ def name
+ category.name
end
def actual_spending
- category.month_total(date: budget.start_date)
+ budget.budget_category_actual_spending(self)
+ end
+
+ def avg_monthly_expense
+ budget.category_avg_monthly_expense(category)
+ end
+
+ def median_monthly_expense
+ budget.category_median_monthly_expense(category)
+ end
+
+ def subcategory?
+ category.parent_id.present?
end
def available_to_spend
diff --git a/app/models/budgeting_stats.rb b/app/models/budgeting_stats.rb
deleted file mode 100644
index fdda0dff..00000000
--- a/app/models/budgeting_stats.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-class BudgetingStats
- attr_reader :family
-
- def initialize(family)
- @family = family
- end
-
- def avg_monthly_income
- income_expense_totals_query(Account::Entry.incomes)
- end
-
- def avg_monthly_expenses
- income_expense_totals_query(Account::Entry.expenses)
- end
-
- private
- def income_expense_totals_query(type_scope)
- monthly_totals = family.entries
- .merge(type_scope)
- .select("SUM(account_entries.amount) as total")
- .group(Arel.sql("date_trunc('month', account_entries.date)"))
-
- result = Family.select("AVG(mt.total)")
- .from(monthly_totals, :mt)
- .pick("AVG(mt.total)")
-
- result&.round(2)
- end
-end
diff --git a/app/models/category.rb b/app/models/category.rb
index 4837c9cf..15e8e0bc 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -108,30 +108,6 @@ class Category < ApplicationRecord
parent.present?
end
- def avg_monthly_total
- family.category_stats.avg_monthly_total_for(self)
- end
-
- def median_monthly_total
- family.category_stats.median_monthly_total_for(self)
- end
-
- def month_total(date: Date.current)
- family.category_stats.month_total_for(self, date: date)
- end
-
- def avg_monthly_total_money
- Money.new(avg_monthly_total, family.currency)
- end
-
- def median_monthly_total_money
- Money.new(median_monthly_total, family.currency)
- end
-
- def month_total_money(date: Date.current)
- Money.new(month_total(date: date), family.currency)
- end
-
private
def category_level_limit
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
@@ -144,4 +120,8 @@ class Category < ApplicationRecord
errors.add(:parent, "must have the same classification as its parent")
end
end
+
+ def monetizable_currency
+ family.currency
+ end
end
diff --git a/app/models/category_stats.rb b/app/models/category_stats.rb
deleted file mode 100644
index 631b95ee..00000000
--- a/app/models/category_stats.rb
+++ /dev/null
@@ -1,179 +0,0 @@
-class CategoryStats
- attr_reader :family
-
- def initialize(family)
- @family = family
- end
-
- def avg_monthly_total_for(category)
- statistics_data[category.id]&.avg || 0
- end
-
- def median_monthly_total_for(category)
- statistics_data[category.id]&.median || 0
- end
-
- def month_total_for(category, date: Date.current)
- monthly_totals = totals_data[category.id]
-
- category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
-
- category_total&.amount || 0
- end
-
- def month_category_totals(date: Date.current)
- by_classification = Hash.new { |h, k| h[k] = {} }
-
- totals_data.each_with_object(by_classification) do |(category_id, totals), result|
- totals.each do |t|
- next unless t.month == date.month && t.year == date.year
- result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
- result[t.classification][category_id][:amount] += t.amount.abs
- end
- end
-
- # Calculate percentages for each group
- category_totals = []
-
- [ "income", "expense" ].each do |classification|
- totals = by_classification[classification]
-
- # Only include non-subcategory amounts in the total for percentage calculations
- total_amount = totals.sum do |_, data|
- data[:subcategory] ? 0 : data[:amount]
- end
-
- next if total_amount.zero?
-
- totals.each do |category_id, data|
- percentage = (data[:amount].to_f / total_amount * 100).round(1)
-
- category_totals << CategoryTotal.new(
- category_id: category_id,
- amount: data[:amount],
- percentage: percentage,
- classification: classification,
- currency: family.currency,
- subcategory?: data[:subcategory]
- )
- end
- end
-
- # Calculate totals based on non-subcategory amounts only
- total_income = category_totals
- .select { |ct| ct.classification == "income" && !ct.subcategory? }
- .sum(&:amount)
-
- total_expense = category_totals
- .select { |ct| ct.classification == "expense" && !ct.subcategory? }
- .sum(&:amount)
-
- CategoryTotals.new(
- total_income: total_income,
- total_expense: total_expense,
- category_totals: category_totals
- )
- end
-
- private
- Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
- Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
- CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
- CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
-
- def statistics_data
- @statistics_data ||= begin
- stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
- next if totals.empty?
-
- amounts = totals.map(&:amount)
- hash[category_id] = Stats.new(
- avg: (amounts.sum.to_f / amounts.size).round,
- median: calculate_median(amounts),
- currency: family.currency
- )
- end
- end
- end
-
- def totals_data
- @totals_data ||= begin
- totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
- hash[row.category_id] ||= []
- existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
-
- if existing_total
- existing_total.amount += row.total.to_i
- else
- hash[row.category_id] << Totals.new(
- month: row.date.month,
- year: row.date.year,
- amount: row.total.to_i,
- classification: row.classification,
- currency: family.currency,
- subcategory?: row.parent_category_id.present?
- )
- end
-
- # If category is a parent, its total includes its own transactions + sum(child category transactions)
- if row.parent_category_id
- hash[row.parent_category_id] ||= []
-
- existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
-
- if existing_parent_total
- existing_parent_total.amount += row.total.to_i
- else
- hash[row.parent_category_id] << Totals.new(
- month: row.date.month,
- year: row.date.year,
- amount: row.total.to_i,
- classification: row.classification,
- currency: family.currency,
- subcategory?: false
- )
- end
- end
- end
-
- # Ensure we have a default empty array for nil category, which represents "Uncategorized"
- totals[nil] ||= []
- totals
- end
- end
-
- def monthly_totals_query
- income_expense_classification = Arel.sql("
- CASE WHEN categories.id IS NULL THEN
- CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
- ELSE categories.classification
- END
- ")
-
- family.entries
- .incomes_and_expenses
- .select(
- "categories.id as category_id",
- "categories.parent_id as parent_category_id",
- income_expense_classification,
- "date_trunc('month', account_entries.date) as date",
- "SUM(account_entries.amount) as total"
- )
- .joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
- .group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
- .order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
- end
-
-
- def calculate_median(numbers)
- return 0 if numbers.empty?
-
- sorted = numbers.sort
- mid = sorted.size / 2
- if sorted.size.odd?
- sorted[mid]
- else
- ((sorted[mid-1] + sorted[mid]) / 2.0).round
- end
- end
-end
diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb
index 73e5ef69..c3e2b783 100644
--- a/app/models/concerns/accountable.rb
+++ b/app/models/concerns/accountable.rb
@@ -1,23 +1,50 @@
module Accountable
extend ActiveSupport::Concern
- ASSET_TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset]
- LIABILITY_TYPES = %w[CreditCard Loan OtherLiability]
- TYPES = ASSET_TYPES + LIABILITY_TYPES
+ TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability]
def self.from_type(type)
return nil unless TYPES.include?(type)
type.constantize
end
- def self.by_classification
- { assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
- end
-
included do
has_one :account, as: :accountable, touch: true
end
+ class_methods do
+ def classification
+ raise NotImplementedError, "Accountable must implement #classification"
+ end
+
+ def icon
+ raise NotImplementedError, "Accountable must implement #icon"
+ end
+
+ def color
+ raise NotImplementedError, "Accountable must implement #color"
+ end
+
+ def favorable_direction
+ classification == "asset" ? "up" : "down"
+ end
+
+ def display_name
+ self.name.pluralize.titleize
+ end
+
+ def balance_money(family)
+ family.accounts
+ .active
+ .joins(sanitize_sql_array([
+ "LEFT JOIN exchange_rates ON exchange_rates.date = :current_date AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = :family_currency",
+ { current_date: Date.current.to_s, family_currency: family.currency }
+ ]))
+ .where(accountable_type: self.name)
+ .sum("accounts.balance * COALESCE(exchange_rates.rate, 1)")
+ end
+ end
+
def post_sync
broadcast_replace_to(
account,
@@ -26,4 +53,20 @@ module Accountable
locals: { account: account }
)
end
+
+ def display_name
+ self.class.display_name
+ end
+
+ def icon
+ self.class.icon
+ end
+
+ def color
+ self.class.color
+ end
+
+ def classification
+ self.class.classification
+ end
end
diff --git a/app/models/concerns/monetizable.rb b/app/models/concerns/monetizable.rb
index 8179271b..e80fcb5f 100644
--- a/app/models/concerns/monetizable.rb
+++ b/app/models/concerns/monetizable.rb
@@ -4,11 +4,19 @@ module Monetizable
class_methods do
def monetize(*fields)
fields.each do |field|
- define_method("#{field}_money") do
- value = self.send(field)
- value.nil? ? nil : Money.new(value, currency || Money.default_currency)
+ define_method("#{field}_money") do |**args|
+ value = self.send(field, **args)
+
+ return nil if value.nil? || monetizable_currency.nil?
+
+ Money.new(value, monetizable_currency)
end
end
end
end
+
+ private
+ def monetizable_currency
+ currency
+ end
end
diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb
index dc269516..fa621546 100644
--- a/app/models/credit_card.rb
+++ b/app/models/credit_card.rb
@@ -1,6 +1,20 @@
class CreditCard < ApplicationRecord
include Accountable
+ class << self
+ def color
+ "#F13636"
+ end
+
+ def icon
+ "credit-card"
+ end
+
+ def classification
+ "liability"
+ end
+ end
+
def available_credit_money
available_credit ? Money.new(available_credit, account.currency) : nil
end
@@ -12,12 +26,4 @@ class CreditCard < ApplicationRecord
def annual_fee_money
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
-
- def color
- "#F13636"
- end
-
- def icon
- "credit-card"
- end
end
diff --git a/app/models/crypto.rb b/app/models/crypto.rb
index 1aba075e..7724a953 100644
--- a/app/models/crypto.rb
+++ b/app/models/crypto.rb
@@ -1,11 +1,21 @@
class Crypto < ApplicationRecord
include Accountable
- def color
- "#737373"
- end
+ class << self
+ def color
+ "#737373"
+ end
- def icon
- "bitcoin"
+ def classification
+ "asset"
+ end
+
+ def icon
+ "bitcoin"
+ end
+
+ def display_name
+ "Crypto"
+ end
end
end
diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb
index 283ae35b..bc5036eb 100644
--- a/app/models/demo/generator.rb
+++ b/app/models/demo/generator.rb
@@ -61,6 +61,34 @@ class Demo::Generator
puts "Demo data loaded successfully!"
end
+ def generate_multi_currency_data!
+ puts "Clearing existing data..."
+
+ destroy_everything!
+
+ puts "Data cleared"
+
+ create_family_and_user!("Demo Family 1", "user@maybe.local", currency: "EUR")
+
+ family = Family.find_by(name: "Demo Family 1")
+
+ puts "Users reset"
+
+ usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
+ eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
+
+ puts "Accounts created"
+
+ create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
+ create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
+ create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
+ create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
+
+ puts "Transactions created"
+
+ puts "Demo data loaded successfully!"
+ end
+
private
def destroy_everything!
Family.destroy_all
@@ -71,13 +99,14 @@ class Demo::Generator
Security::Price.destroy_all
end
- def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false)
+ def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false, currency: "USD")
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
id = Digest::UUID.uuid_v5(base_uuid, family_name)
family = Family.create!(
id: id,
name: family_name,
+ currency: currency,
stripe_subscription_status: "active",
data_enrichment_enabled: data_enrichment_enabled,
locale: "en",
@@ -161,19 +190,21 @@ class Demo::Generator
balance: 15000,
currency: "USD"
+ # First create income transactions to ensure positive balance
+ 50.times do
+ create_transaction! \
+ account: checking,
+ amount: Faker::Number.negative(from: -2000, to: -500),
+ name: "Income",
+ category: family.categories.find_by(name: "Income")
+ end
+
+ # Then create expenses that won't exceed the income
200.times do
create_transaction! \
account: checking,
name: "Expense",
- amount: Faker::Number.positive(from: 100, to: 1000)
- end
-
- 50.times do
- create_transaction! \
- account: checking,
- amount: Faker::Number.negative(from: -2000),
- name: "Income",
- category: family.categories.find_by(name: "Income")
+ amount: Faker::Number.positive(from: 8, to: 500)
end
end
@@ -185,14 +216,23 @@ class Demo::Generator
currency: "USD",
subtype: "savings"
+ # Create larger income deposits first
100.times do
create_transaction! \
account: savings,
- amount: Faker::Number.negative(from: -2000),
+ amount: Faker::Number.negative(from: -3000, to: -1000),
tags: [ family.tags.find_by(name: "Emergency Fund") ],
category: family.categories.find_by(name: "Income"),
name: "Income"
end
+
+ # Add some smaller withdrawals that won't exceed the deposits
+ 50.times do
+ create_transaction! \
+ account: savings,
+ amount: Faker::Number.positive(from: 100, to: 1000),
+ name: "Savings Withdrawal"
+ end
end
def create_transfer_transactions!(family)
@@ -304,39 +344,50 @@ class Demo::Generator
create_valuation!(house, 2.years.ago.to_date, 540000)
create_valuation!(house, 1.years.ago.to_date, 550000)
- family.accounts.create! \
+ mortgage = family.accounts.create! \
accountable: Loan.new,
name: "Mortgage",
balance: 495000,
currency: "USD"
+
+ create_valuation!(mortgage, 3.years.ago.to_date, 495000)
+ create_valuation!(mortgage, 2.years.ago.to_date, 490000)
+ create_valuation!(mortgage, 1.years.ago.to_date, 485000)
end
def create_car_and_loan!(family)
- family.accounts.create! \
+ vehicle = family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
balance: 18000,
currency: "USD"
- family.accounts.create! \
+ create_valuation!(vehicle, 1.year.ago.to_date, 18000)
+
+ loan = family.accounts.create! \
accountable: Loan.new,
name: "Car Loan",
balance: 8000,
currency: "USD"
+
+ create_valuation!(loan, 1.year.ago.to_date, 8000)
end
def create_other_accounts!(family)
- family.accounts.create! \
+ other_asset = family.accounts.create! \
accountable: OtherAsset.new,
name: "Other Asset",
balance: 10000,
currency: "USD"
- family.accounts.create! \
+ other_liability = family.accounts.create! \
accountable: OtherLiability.new,
name: "Other Liability",
balance: 5000,
currency: "USD"
+
+ create_valuation!(other_asset, 1.year.ago.to_date, 10000)
+ create_valuation!(other_liability, 1.year.ago.to_date, 5000)
end
def create_transaction!(attributes = {})
diff --git a/app/models/depository.rb b/app/models/depository.rb
index 3b818f43..0e03547c 100644
--- a/app/models/depository.rb
+++ b/app/models/depository.rb
@@ -6,11 +6,21 @@ class Depository < ApplicationRecord
[ "Savings", "savings" ]
].freeze
- def color
- "#875BF7"
- end
+ class << self
+ def display_name
+ "Cash"
+ end
- def icon
- "landmark"
+ def color
+ "#875BF7"
+ end
+
+ def classification
+ "asset"
+ end
+
+ def icon
+ "landmark"
+ end
end
end
diff --git a/app/models/family.rb b/app/models/family.rb
index 4f001119..ff2c8b07 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -1,5 +1,5 @@
class Family < ApplicationRecord
- include Plaidable, Syncable
+ include Providable, Plaidable, Syncable, AutoTransferMatchable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@@ -13,26 +13,37 @@ class Family < ApplicationRecord
[ "YYYY.MM.DD", "%Y.%m.%d" ]
].freeze
- include Providable
-
has_many :users, dependent: :destroy
- has_many :invitations, dependent: :destroy
- has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
+ has_many :plaid_items, dependent: :destroy
+ has_many :invitations, dependent: :destroy
+
has_many :imports, dependent: :destroy
- has_many :transactions, through: :accounts
+ has_many :issues, through: :accounts
+
has_many :entries, through: :accounts
+ has_many :transactions, through: :accounts
+ has_many :trades, through: :accounts
+ has_many :holdings, through: :accounts
+
+ has_many :tags, dependent: :destroy
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
- has_many :issues, through: :accounts
- has_many :holdings, through: :accounts
- has_many :plaid_items, dependent: :destroy
+
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
+ def balance_sheet
+ @balance_sheet ||= BalanceSheet.new(self)
+ end
+
+ def income_statement
+ @income_statement ||= IncomeStatement.new(self)
+ end
+
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
@@ -81,120 +92,6 @@ class Family < ApplicationRecord
).link_token
end
- def income_categories_with_totals(date: Date.current)
- categories_with_stats(classification: "income", date: date)
- end
-
- def expense_categories_with_totals(date: Date.current)
- categories_with_stats(classification: "expense", date: date)
- end
-
- def category_stats
- CategoryStats.new(self)
- end
-
- def budgeting_stats
- BudgetingStats.new(self)
- end
-
- def snapshot(period = Period.all)
- query = accounts.active.joins(:balances)
- .where("account_balances.currency = ?", self.currency)
- .select(
- "account_balances.currency",
- "account_balances.date",
- "SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
- "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
- "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
- )
- .group("account_balances.date, account_balances.currency")
- .order("account_balances.date")
-
- query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
- query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
- result = query.to_a
-
- {
- asset_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.assets, r.currency) } }),
- liability_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.liabilities, r.currency) } }, favorable_direction: "down"),
- net_worth_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.net_worth, r.currency) } })
- }
- end
-
- def snapshot_account_transactions
- period = Period.last_30_days
- results = accounts.active
- .joins(:entries)
- .joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
- .select(
- "accounts.*",
- "COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
- "COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
- )
- .where("account_entries.date >= ?", period.date_range.begin)
- .where("account_entries.date <= ?", period.date_range.end)
- .where("account_entries.entryable_type = 'Account::Transaction'")
- .where("transfers.id IS NULL")
- .group("accounts.id")
- .having("SUM(ABS(account_entries.amount)) > 0")
- .to_a
-
- results.each do |r|
- r.define_singleton_method(:savings_rate) do
- (income - spending) / income
- end
- end
-
- {
- top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
- top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse,
- top_savers: results.sort_by { |a| a.savings_rate }.reverse
- }
- end
-
- def snapshot_transactions
- candidate_entries = entries.account_transactions.incomes_and_expenses
- rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
-
- spending = []
- income = []
- savings = []
- rolling_totals.each do |r|
- spending << {
- date: r.date,
- value: Money.new(r.rolling_spend, self.currency)
- }
-
- income << {
- date: r.date,
- value: Money.new(r.rolling_income, self.currency)
- }
-
- savings << {
- date: r.date,
- value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
- }
- end
-
- {
- income_series: TimeSeries.new(income, favorable_direction: "up"),
- spending_series: TimeSeries.new(spending, favorable_direction: "down"),
- savings_rate_series: TimeSeries.new(savings, favorable_direction: "up")
- }
- end
-
- def net_worth
- assets - liabilities
- end
-
- def assets
- Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
- end
-
- def liabilities
- Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
- end
-
def synth_usage
self.class.synth_provider&.usage
end
@@ -223,36 +120,13 @@ class Family < ApplicationRecord
accounts.active.count
end
- private
- CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
- CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
-
- def categories_with_stats(classification:, date: Date.current)
- totals = category_stats.month_category_totals(date: date)
-
- classified_totals = totals.category_totals.select { |t| t.classification == classification }
-
- if classification == "income"
- total = totals.total_income
- categories_scope = categories.incomes
- else
- total = totals.total_expense
- categories_scope = categories.expenses
- end
-
- categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
-
- CategoriesWithTotals.new(
- total_money: Money.new(total, currency),
- category_totals: categories_with_uncategorized.map do |category|
- ct = classified_totals.find { |ct| ct.category_id == category&.id }
-
- CategoryWithStats.new(
- category: category,
- amount_money: Money.new(ct&.amount || 0, currency),
- percentage: ct&.percentage || 0
- )
- end
- )
- end
+ # Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
+ def build_cache_key(key)
+ [
+ "family",
+ id,
+ key,
+ entries.maximum(:updated_at)
+ ].compact.join("_")
+ end
end
diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb
new file mode 100644
index 00000000..32fe94b4
--- /dev/null
+++ b/app/models/family/auto_transfer_matchable.rb
@@ -0,0 +1,61 @@
+module Family::AutoTransferMatchable
+ def transfer_match_candidates
+ Account::Entry.select([
+ "inflow_candidates.entryable_id as inflow_transaction_id",
+ "outflow_candidates.entryable_id as outflow_transaction_id",
+ "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
+ ]).from("account_entries inflow_candidates")
+ .joins("
+ JOIN account_entries outflow_candidates ON (
+ inflow_candidates.amount < 0 AND
+ outflow_candidates.amount > 0 AND
+ inflow_candidates.amount = -outflow_candidates.amount AND
+ inflow_candidates.currency = outflow_candidates.currency AND
+ inflow_candidates.account_id <> outflow_candidates.account_id AND
+ inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
+ )
+ ").joins("
+ LEFT JOIN transfers existing_transfers ON (
+ existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
+ existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
+ )
+ ")
+ .joins("LEFT JOIN rejected_transfers ON (
+ rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
+ rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
+ )")
+ .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
+ .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
+ .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.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(existing_transfers: { id: nil })
+ .order("date_diff ASC") # Closest matches first
+ end
+
+ def auto_match_transfers!
+ # Exclude already matched transfers
+ candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
+
+ # Track which transactions we've already matched to avoid duplicates
+ used_transaction_ids = Set.new
+
+ candidates = []
+
+ Transfer.transaction do
+ candidates_scope.each do |match|
+ next if used_transaction_ids.include?(match.inflow_transaction_id) ||
+ used_transaction_ids.include?(match.outflow_transaction_id)
+
+ Transfer.create!(
+ inflow_transaction_id: match.inflow_transaction_id,
+ outflow_transaction_id: match.outflow_transaction_id,
+ )
+
+ used_transaction_ids << match.inflow_transaction_id
+ used_transaction_ids << match.outflow_transaction_id
+ end
+ end
+ end
+end
diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb
new file mode 100644
index 00000000..52ad030c
--- /dev/null
+++ b/app/models/income_statement.rb
@@ -0,0 +1,118 @@
+class IncomeStatement
+ include Monetizable
+
+ monetize :median_expense, :median_income
+
+ attr_reader :family
+
+ def initialize(family)
+ @family = family
+ end
+
+ def totals(transactions_scope: nil)
+ transactions_scope ||= family.transactions.active
+
+ result = totals_query(transactions_scope: transactions_scope)
+
+ total_income = result.select { |t| t.classification == "income" }.sum(&:total)
+ total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
+
+ ScopeTotals.new(
+ transactions_count: transactions_scope.count,
+ income_money: Money.new(total_income, family.currency),
+ expense_money: Money.new(total_expense, family.currency),
+ missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
+ )
+ end
+
+ def expense_totals(period: Period.current_month)
+ build_period_total(classification: "expense", period: period)
+ end
+
+ def income_totals(period: Period.current_month)
+ build_period_total(classification: "income", period: period)
+ end
+
+ def median_expense(interval: "month", category: nil)
+ if category.present?
+ category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0
+ else
+ family_stats(interval: interval).find { |stat| stat.classification == "expense" }&.median || 0
+ end
+ end
+
+ def avg_expense(interval: "month", category: nil)
+ if category.present?
+ category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.avg || 0
+ else
+ family_stats(interval: interval).find { |stat| stat.classification == "expense" }&.avg || 0
+ end
+ end
+
+ def median_income(interval: "month")
+ family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
+ end
+
+ private
+ ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
+ PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
+ CategoryTotal = Data.define(:category, :total, :currency, :weight)
+
+ def categories
+ @categories ||= family.categories.all.to_a
+ end
+
+ def build_period_total(classification:, period:)
+ totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification }
+ classification_total = totals.sum(&:total)
+
+ category_totals = totals.map do |ct|
+ # If parent category is nil, it's a top-level category. This means we need to
+ # sum itself + SUM(children) to get the overall category total
+ children_totals = if ct.parent_category_id.nil? && ct.category_id.present?
+ totals.select { |t| t.parent_category_id == ct.category_id }.sum(&:total)
+ else
+ 0
+ end
+
+ category_total = ct.total + children_totals
+
+ weight = (category_total.zero? ? 0 : category_total.to_f / classification_total) * 100
+
+ CategoryTotal.new(
+ category: categories.find { |c| c.id == ct.category_id } || family.categories.uncategorized,
+ total: category_total,
+ currency: family.currency,
+ weight: weight,
+ )
+ end
+
+ PeriodTotal.new(
+ classification: classification,
+ total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
+ currency: family.currency,
+ missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
+ category_totals: category_totals
+ )
+ end
+
+ def family_stats(interval: "month")
+ @family_stats ||= {}
+ @family_stats[interval] ||= FamilyStats.new(family, interval:).call
+ end
+
+ def category_stats(interval: "month")
+ @category_stats ||= {}
+ @category_stats[interval] ||= CategoryStats.new(family, interval:).call
+ end
+
+ def totals_query(transactions_scope:)
+ @totals_query_cache ||= {}
+ cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql)
+ @totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call
+ end
+
+ def monetizable_currency
+ family.currency
+ end
+end
diff --git a/app/models/income_statement/base_query.rb b/app/models/income_statement/base_query.rb
new file mode 100644
index 00000000..baa09659
--- /dev/null
+++ b/app/models/income_statement/base_query.rb
@@ -0,0 +1,42 @@
+module IncomeStatement::BaseQuery
+ private
+ def base_query_sql(family:, interval:, transactions_scope:)
+ sql = <<~SQL
+ SELECT
+ c.id as category_id,
+ c.parent_id as parent_category_id,
+ date_trunc(:interval, ae.date) as date,
+ CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
+ SUM(ae.amount * COALESCE(er.rate, 1)) as total,
+ 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'
+ 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 accounts a ON a.id = ae.account_id
+ ) transfer_info ON (
+ transfer_info.inflow_transaction_id = at.id OR
+ transfer_info.outflow_transaction_id = at.id
+ )
+ LEFT JOIN exchange_rates er ON (
+ er.date = ae.date AND
+ er.from_currency = ae.currency AND
+ er.to_currency = :target_currency
+ )
+ WHERE (
+ transfer_info.transfer_id IS NULL OR
+ (ae.amount < 0 AND transfer_info.accountable_type = 'Loan')
+ )
+ GROUP BY 1, 2, 3, 4
+ SQL
+
+ ActiveRecord::Base.sanitize_sql_array([
+ sql,
+ { target_currency: family.currency, interval: interval }
+ ])
+ end
+end
diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb
new file mode 100644
index 00000000..9f971d99
--- /dev/null
+++ b/app/models/income_statement/category_stats.rb
@@ -0,0 +1,41 @@
+class IncomeStatement::CategoryStats
+ include IncomeStatement::BaseQuery
+
+ def initialize(family, interval: "month")
+ @family = family
+ @interval = interval
+ end
+
+ def call
+ ActiveRecord::Base.connection.select_all(query_sql).map do |row|
+ StatRow.new(
+ category_id: row["category_id"],
+ classification: row["classification"],
+ median: row["median"],
+ avg: row["avg"],
+ missing_exchange_rates?: row["missing_exchange_rates"]
+ )
+ end
+ end
+
+ private
+ StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
+
+ def query_sql
+ base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
+
+ <<~SQL
+ WITH base_totals AS (
+ #{base_sql}
+ )
+ SELECT
+ category_id,
+ classification,
+ ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
+ ABS(AVG(total)) as avg,
+ BOOL_OR(missing_exchange_rates) as missing_exchange_rates
+ FROM base_totals
+ GROUP BY category_id, classification;
+ SQL
+ end
+end
diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb
new file mode 100644
index 00000000..2edece2e
--- /dev/null
+++ b/app/models/income_statement/family_stats.rb
@@ -0,0 +1,47 @@
+class IncomeStatement::FamilyStats
+ include IncomeStatement::BaseQuery
+
+ def initialize(family, interval: "month")
+ @family = family
+ @interval = interval
+ end
+
+ def call
+ ActiveRecord::Base.connection.select_all(query_sql).map do |row|
+ StatRow.new(
+ classification: row["classification"],
+ median: row["median"],
+ avg: row["avg"],
+ missing_exchange_rates?: row["missing_exchange_rates"]
+ )
+ end
+ end
+
+ private
+ StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
+
+ def query_sql
+ base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
+
+ <<~SQL
+ WITH base_totals AS (
+ #{base_sql}
+ ), aggregated_totals AS (
+ SELECT
+ date,
+ classification,
+ SUM(total) as total,
+ BOOL_OR(missing_exchange_rates) as missing_exchange_rates
+ FROM base_totals
+ GROUP BY date, classification
+ )
+ SELECT
+ classification,
+ ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
+ ABS(AVG(total)) as avg,
+ BOOL_OR(missing_exchange_rates) as missing_exchange_rates
+ FROM aggregated_totals
+ GROUP BY classification;
+ SQL
+ end
+end
diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb
new file mode 100644
index 00000000..acf2017b
--- /dev/null
+++ b/app/models/income_statement/totals.rb
@@ -0,0 +1,41 @@
+class IncomeStatement::Totals
+ include IncomeStatement::BaseQuery
+
+ def initialize(family, transactions_scope:)
+ @family = family
+ @transactions_scope = transactions_scope
+ end
+
+ def call
+ ActiveRecord::Base.connection.select_all(query_sql).map do |row|
+ TotalsRow.new(
+ parent_category_id: row["parent_category_id"],
+ category_id: row["category_id"],
+ classification: row["classification"],
+ total: row["total"],
+ missing_exchange_rates?: row["missing_exchange_rates"]
+ )
+ end
+ end
+
+ private
+ TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :missing_exchange_rates?)
+
+ def query_sql
+ base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
+
+ <<~SQL
+ WITH base_totals AS (
+ #{base_sql}
+ )
+ SELECT
+ parent_category_id,
+ category_id,
+ classification,
+ ABS(SUM(total)) as total,
+ BOOL_OR(missing_exchange_rates) as missing_exchange_rates
+ FROM base_totals
+ GROUP BY 1, 2, 3;
+ SQL
+ end
+end
diff --git a/app/models/investment.rb b/app/models/investment.rb
index 76e7a57c..1dabae3e 100644
--- a/app/models/investment.rb
+++ b/app/models/investment.rb
@@ -16,11 +16,17 @@ class Investment < ApplicationRecord
[ "Angel", "angel" ]
].freeze
- def color
- "#1570EF"
- end
+ class << self
+ def color
+ "#1570EF"
+ end
- def icon
- "line-chart"
+ def classification
+ "asset"
+ end
+
+ def icon
+ "line-chart"
+ end
end
end
diff --git a/app/models/loan.rb b/app/models/loan.rb
index 41bd4d7b..14b4d084 100644
--- a/app/models/loan.rb
+++ b/app/models/loan.rb
@@ -17,11 +17,17 @@ class Loan < ApplicationRecord
Money.new(payment.round, account.currency)
end
- def color
- "#D444F1"
- end
+ class << self
+ def color
+ "#D444F1"
+ end
- def icon
- "hand-coins"
+ def icon
+ "hand-coins"
+ end
+
+ def classification
+ "liability"
+ end
end
end
diff --git a/app/models/other_asset.rb b/app/models/other_asset.rb
index 68d3915e..ad4d5c7c 100644
--- a/app/models/other_asset.rb
+++ b/app/models/other_asset.rb
@@ -1,11 +1,17 @@
class OtherAsset < ApplicationRecord
include Accountable
- def color
- "#12B76A"
- end
+ class << self
+ def color
+ "#12B76A"
+ end
- def icon
- "plus"
+ def icon
+ "plus"
+ end
+
+ def classification
+ "asset"
+ end
end
end
diff --git a/app/models/other_liability.rb b/app/models/other_liability.rb
index 18e44171..03ef07ba 100644
--- a/app/models/other_liability.rb
+++ b/app/models/other_liability.rb
@@ -1,11 +1,17 @@
class OtherLiability < ApplicationRecord
include Accountable
- def color
- "#737373"
- end
+ class << self
+ def color
+ "#737373"
+ end
- def icon
- "minus"
+ def icon
+ "minus"
+ end
+
+ def classification
+ "liability"
+ end
end
end
diff --git a/app/models/period.rb b/app/models/period.rb
index c1d28e99..65825058 100644
--- a/app/models/period.rb
+++ b/app/models/period.rb
@@ -1,46 +1,167 @@
class Period
- attr_reader :name, :date_range
+ include ActiveModel::Validations, Comparable
+
+ attr_reader :start_date, :end_date
+
+ validates :start_date, :end_date, presence: true
+ validate :must_be_valid_date_range
+
+ PERIODS = {
+ "last_day" => {
+ date_range: [ 1.day.ago.to_date, Date.current ],
+ label_short: "1D",
+ label: "Last Day",
+ comparison_label: "vs. yesterday"
+ },
+ "current_week" => {
+ date_range: [ Date.current.beginning_of_week, Date.current ],
+ label_short: "WTD",
+ label: "Current Week",
+ comparison_label: "vs. start of week"
+ },
+ "last_7_days" => {
+ date_range: [ 7.days.ago.to_date, Date.current ],
+ label_short: "7D",
+ label: "Last 7 Days",
+ comparison_label: "vs. last week"
+ },
+ "current_month" => {
+ date_range: [ Date.current.beginning_of_month, Date.current ],
+ label_short: "MTD",
+ label: "Current Month",
+ comparison_label: "vs. start of month"
+ },
+ "last_30_days" => {
+ date_range: [ 30.days.ago.to_date, Date.current ],
+ label_short: "30D",
+ label: "Last 30 Days",
+ comparison_label: "vs. last month"
+ },
+ "last_90_days" => {
+ date_range: [ 90.days.ago.to_date, Date.current ],
+ label_short: "90D",
+ label: "Last 90 Days",
+ comparison_label: "vs. last quarter"
+ },
+ "current_year" => {
+ date_range: [ Date.current.beginning_of_year, Date.current ],
+ label_short: "YTD",
+ label: "Current Year",
+ comparison_label: "vs. start of year"
+ },
+ "last_365_days" => {
+ date_range: [ 365.days.ago.to_date, Date.current ],
+ label_short: "365D",
+ label: "Last 365 Days",
+ comparison_label: "vs. 1 year ago"
+ },
+ "last_5_years" => {
+ date_range: [ 5.years.ago.to_date, Date.current ],
+ label_short: "5Y",
+ label: "Last 5 Years",
+ comparison_label: "vs. 5 years ago"
+ }
+ }
class << self
- def from_param(param)
- find_by_name(param) || self.last_30_days
+ def default
+ from_key("last_30_days")
end
- def find_by_name(name)
- INDEX[name]
+ def from_key(key, fallback: false)
+ if PERIODS[key].present?
+ start_date, end_date = PERIODS[key].fetch(:date_range)
+ new(start_date: start_date, end_date: end_date)
+ else
+ return default if fallback
+ raise ArgumentError, "Invalid period key: #{key}"
+ end
end
- def names
- INDEX.keys.sort
+ def all
+ PERIODS.map { |key, period| from_key(key) }
end
end
- def initialize(name: "custom", date_range:)
- @name = name
- @date_range = date_range
- end
-
- def extend_backward(duration)
- Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last)
- end
-
- BUILTIN = [
- new(name: "all", date_range: nil..Date.current),
- new(name: "current_week", date_range: Date.current.beginning_of_week..Date.current),
- new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
- new(name: "current_month", date_range: Date.current.beginning_of_month..Date.current),
- new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
- new(name: "current_quarter", date_range: Date.current.beginning_of_quarter..Date.current),
- new(name: "last_90_days", date_range: 90.days.ago.to_date..Date.current),
- new(name: "current_year", date_range: Date.current.beginning_of_year..Date.current),
- new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
- ]
-
- INDEX = BUILTIN.index_by(&:name)
-
- BUILTIN.each do |period|
- define_singleton_method(period.name) do
- period
+ PERIODS.each do |key, period|
+ define_singleton_method(key) do
+ start_date, end_date = period.fetch(:date_range)
+ new(start_date: start_date, end_date: end_date)
end
end
+
+ def initialize(start_date:, end_date:, date_format: "%b %d, %Y")
+ @start_date = start_date
+ @end_date = end_date
+ @date_format = date_format
+ validate!
+ end
+
+ def <=>(other)
+ [ start_date, end_date ] <=> [ other.start_date, other.end_date ]
+ end
+
+ def date_range
+ start_date..end_date
+ end
+
+ def days
+ (end_date - start_date).to_i + 1
+ end
+
+ def within?(other)
+ start_date >= other.start_date && end_date <= other.end_date
+ end
+
+ def interval
+ if days > 90
+ "1 month"
+ else
+ "1 day"
+ end
+ end
+
+ def key
+ PERIODS.find { |_, period| period.fetch(:date_range) == [ start_date, end_date ] }&.first
+ end
+
+ def label
+ if known?
+ PERIODS[key].fetch(:label)
+ else
+ "Custom Period"
+ end
+ end
+
+ def label_short
+ if known?
+ PERIODS[key].fetch(:label_short)
+ else
+ "CP"
+ end
+ end
+
+ def comparison_label
+ if known?
+ PERIODS[key].fetch(:comparison_label)
+ else
+ "#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}"
+ end
+ end
+
+ private
+ def known?
+ key.present?
+ end
+
+ def must_be_valid_date_range
+ return if start_date.nil? || end_date.nil?
+ unless start_date.is_a?(Date) && end_date.is_a?(Date)
+ errors.add(:start_date, "must be a valid date")
+ errors.add(:end_date, "must be a valid date")
+ return
+ end
+
+ errors.add(:start_date, "must be before end date") if start_date > end_date
+ end
end
diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb
index 1fdf40ee..186105ce 100644
--- a/app/models/plaid_account.rb
+++ b/app/models/plaid_account.rb
@@ -115,12 +115,6 @@ class PlaidAccount < ApplicationRecord
plaid_item.family
end
- def transfer?(plaid_txn)
- transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
-
- transfer_categories.include?(plaid_txn.personal_finance_category.primary)
- end
-
def create_initial_loan_balance(loan_data)
if loan_data.origination_principal_amount.present? && loan_data.origination_date.present?
account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e|
diff --git a/app/models/property.rb b/app/models/property.rb
index ec1d530f..b30a1071 100644
--- a/app/models/property.rb
+++ b/app/models/property.rb
@@ -16,6 +16,20 @@ class Property < ApplicationRecord
attribute :area_unit, :string, default: "sqft"
+ class << self
+ def icon
+ "home"
+ end
+
+ def color
+ "#06AED4"
+ end
+
+ def classification
+ "asset"
+ end
+ end
+
def area
Measurement.new(area_value, area_unit) if area_value.present?
end
@@ -25,15 +39,7 @@ class Property < ApplicationRecord
end
def trend
- TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
- end
-
- def color
- "#06AED4"
- end
-
- def icon
- "home"
+ Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
private
diff --git a/app/models/series.rb b/app/models/series.rb
new file mode 100644
index 00000000..0447ebc4
--- /dev/null
+++ b/app/models/series.rb
@@ -0,0 +1,57 @@
+class Series
+ attr_reader :start_date, :end_date, :interval, :trend, :values
+
+ Value = Struct.new(
+ :date,
+ :date_formatted,
+ :trend,
+ keyword_init: true
+ )
+
+ class << self
+ def from_raw_values(values, interval: "1 day")
+ raise ArgumentError, "Must be an array of at least 2 values" unless values.size >= 2
+ raise ArgumentError, "Must have date and value properties" unless values.all? { |value| value.has_key?(:date) && value.has_key?(:value) }
+
+ ordered = values.sort_by { |value| value[:date] }
+ start_date = ordered.first[:date]
+ end_date = ordered.last[:date]
+
+ new(
+ start_date: start_date,
+ end_date: end_date,
+ interval: interval,
+ trend: Trend.new(
+ current: ordered.last[:value],
+ previous: ordered.first[:value]
+ ),
+ values: [ nil, *ordered ].each_cons(2).map do |prev_value, curr_value|
+ Value.new(
+ date: curr_value[:date],
+ date_formatted: I18n.l(curr_value[:date], format: :long),
+ trend: Trend.new(
+ current: curr_value[:value],
+ previous: prev_value&.[](:value)
+ )
+ )
+ end
+ )
+ end
+ end
+
+ def initialize(start_date:, end_date:, interval:, trend:, values:)
+ @start_date = start_date
+ @end_date = end_date
+ @interval = interval
+ @trend = trend
+ @values = values
+ end
+
+ def current
+ values.last.trend.current
+ end
+
+ def any?
+ values.any?
+ end
+end
diff --git a/app/models/time_series.rb b/app/models/time_series.rb
deleted file mode 100644
index f9ef3ffb..00000000
--- a/app/models/time_series.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-class TimeSeries
- DIRECTIONS = %w[up down].freeze
-
- attr_reader :values, :favorable_direction
-
- def self.from_collection(collection, value_method, favorable_direction: "up")
- collection.map do |obj|
- {
- date: obj.date,
- value: obj.public_send(value_method),
- original: obj
- }
- end.then { |data| new(data, favorable_direction: favorable_direction) }
- end
-
- def initialize(data, favorable_direction: "up")
- @favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
- @values = initialize_values data.sort_by { |d| d[:date] }
- end
-
- def first
- values.first
- end
-
- def last
- values.last
- end
-
- def on(date)
- values.find { |v| v.date == date }
- end
-
- def trend
- TimeSeries::Trend.new \
- current: last&.value,
- previous: first&.value,
- series: self
- end
-
- def empty?
- values.empty?
- end
-
- def has_current_day_value?
- values.any? { |v| v.date == Date.current }
- end
-
- # `as_json` returns the data shape used by D3 charts
- def as_json
- {
- values: values.map(&:as_json),
- trend: trend.as_json,
- favorable_direction: favorable_direction
- }.as_json
- end
-
- private
- def initialize_values(data)
- [ nil, *data ].each_cons(2).map do |previous, current|
- TimeSeries::Value.new **current,
- previous_value: previous.try(:[], :value),
- series: self
- end
- end
-end
diff --git a/app/models/time_series/trend.rb b/app/models/time_series/trend.rb
deleted file mode 100644
index c6638cbe..00000000
--- a/app/models/time_series/trend.rb
+++ /dev/null
@@ -1,131 +0,0 @@
-class TimeSeries::Trend
- include ActiveModel::Validations
-
- attr_reader :current, :previous, :favorable_direction
-
- validate :values_must_be_of_same_type, :values_must_be_of_known_type
-
- def initialize(current:, previous:, series: nil, favorable_direction: nil)
- @current = current
- @previous = previous
- @series = series
- @favorable_direction = get_favorable_direction(favorable_direction)
-
- validate!
- end
-
- def direction
- if previous.nil? || current == previous
- "flat"
- elsif current && current > previous
- "up"
- else
- "down"
- end.inquiry
- end
-
- def color
- case direction
- when "up"
- favorable_direction.down? ? red_hex : green_hex
- when "down"
- favorable_direction.down? ? green_hex : red_hex
- else
- gray_hex
- end
- end
-
- def icon
- if direction.flat?
- "minus"
- elsif direction.up?
- "arrow-up"
- else
- "arrow-down"
- end
- end
-
- def value
- if previous.nil?
- current.is_a?(Money) ? Money.new(0, current.currency) : 0
- else
- current - previous
- end
- end
-
- def percent
- if previous.nil? || (previous.zero? && current.zero?)
- 0.0
- elsif previous.zero?
- Float::INFINITY
- else
- change = (current_amount - previous_amount)
- base = previous_amount.to_f
-
- (change / base * 100).round(1).to_f
- end
- end
-
- def as_json
- {
- favorable_direction: favorable_direction,
- direction: direction,
- value: value,
- percent: percent
- }.as_json
- end
-
- private
-
- attr_reader :series
-
- def red_hex
- "#F13636" # red-500
- end
-
- def green_hex
- "#10A861" # green-600
- end
-
- def gray_hex
- "#737373" # gray-500
- end
-
- def values_must_be_of_same_type
- unless current.class == previous.class || [ previous, current ].any?(&:nil?)
- errors.add :current, :must_be_of_the_same_type_as_previous
- errors.add :previous, :must_be_of_the_same_type_as_current
- end
- end
-
- def values_must_be_of_known_type
- unless current.is_a?(Money) || current.is_a?(Numeric) || current.nil?
- errors.add :current, :must_be_of_type_money_numeric_or_nil
- end
-
- unless previous.is_a?(Money) || previous.is_a?(Numeric) || previous.nil?
- errors.add :previous, :must_be_of_type_money_numeric_or_nil
- end
- end
-
- def current_amount
- extract_numeric current
- end
-
- def previous_amount
- extract_numeric previous
- end
-
- def extract_numeric(obj)
- if obj.is_a? Money
- obj.amount
- else
- obj
- end
- end
-
- def get_favorable_direction(favorable_direction)
- direction = favorable_direction.presence || series&.favorable_direction
- (direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
- end
-end
diff --git a/app/models/time_series/value.rb b/app/models/time_series/value.rb
deleted file mode 100644
index 390343e1..00000000
--- a/app/models/time_series/value.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-class TimeSeries::Value
- include Comparable
- include ActiveModel::Validations
-
- attr_reader :value, :date, :original, :trend
-
- validates :date, presence: true
- validate :value_must_be_of_known_type
-
- def initialize(date:, value:, original: nil, series: nil, previous_value: nil)
- @date, @value, @original, @series = date, value, original, series
- @trend = create_trend previous_value
-
- validate!
- end
-
- def <=>(other)
- result = date <=> other.date
- result = value <=> other.value if result == 0
- result
- end
-
- def as_json
- {
- date: date.iso8601,
- value: value.as_json,
- trend: trend.as_json
- }
- end
-
- private
- attr_reader :series
-
- def create_trend(previous_value)
- TimeSeries::Trend.new \
- current: value,
- previous: previous_value,
- series: series
- end
-
- def value_must_be_of_known_type
- unless value.is_a?(Money) || value.is_a?(Numeric)
- errors.add :value, :must_be_a_money_or_numeric
- end
- end
-end
diff --git a/app/models/trend.rb b/app/models/trend.rb
new file mode 100644
index 00000000..33e2014d
--- /dev/null
+++ b/app/models/trend.rb
@@ -0,0 +1,94 @@
+class Trend
+ include ActiveModel::Validations
+
+ DIRECTIONS = %w[up down].freeze
+
+ attr_reader :current, :previous, :favorable_direction
+
+ validates :current, presence: true
+
+ def initialize(current:, previous:, favorable_direction: nil)
+ @current = current
+ @previous = previous || 0
+ @favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
+
+ validate!
+ end
+
+ def direction
+ if current == previous
+ "flat"
+ elsif current > previous
+ "up"
+ else
+ "down"
+ end.inquiry
+ end
+
+ def color
+ case direction
+ when "up"
+ favorable_direction.down? ? red_hex : green_hex
+ when "down"
+ favorable_direction.down? ? green_hex : red_hex
+ else
+ gray_hex
+ end
+ end
+
+ def icon
+ if direction.flat?
+ "minus"
+ elsif direction.up?
+ "arrow-up"
+ else
+ "arrow-down"
+ end
+ end
+
+ def value
+ current - previous
+ end
+
+ def percent
+ return 0.0 if previous.zero? && current.zero?
+ return Float::INFINITY if previous.zero?
+
+ change = (current - previous).to_f
+
+ (change / previous.to_f * 100).round(1)
+ end
+
+ def percent_formatted
+ if percent.finite?
+ "#{percent.round(1)}%"
+ else
+ percent > 0 ? "+∞" : "-∞"
+ end
+ end
+
+ def as_json
+ {
+ value: value,
+ percent: percent,
+ percent_formatted: percent_formatted,
+ current: current,
+ previous: previous,
+ color: color,
+ icon: icon
+ }
+ end
+
+ private
+ def red_hex
+ "var(--color-destructive)"
+ end
+
+ def green_hex
+ "var(--color-success)"
+ end
+
+ def gray_hex
+ "var(--color-gray)"
+ end
+end
diff --git a/app/models/value_group.rb b/app/models/value_group.rb
deleted file mode 100644
index 275884b9..00000000
--- a/app/models/value_group.rb
+++ /dev/null
@@ -1,104 +0,0 @@
- class ValueGroup
- attr_accessor :parent, :original
- attr_reader :name, :children, :value, :currency
-
- def initialize(name, currency = Money.default_currency)
- @name = name
- @currency = Money::Currency.new(currency)
- @children = []
- end
-
- def sum
- return value if is_value_node?
- return Money.new(0, currency) if children.empty? && value.nil?
- children.sum(&:sum)
- end
-
- def avg
- return value if is_value_node?
- return Money.new(0, currency) if children.empty? && value.nil?
- leaf_values = value_nodes.map(&:value)
- leaf_values.compact.sum / leaf_values.compact.size
- end
-
- def series
- return @series if is_value_node?
-
- summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
- child.series.values.each do |series_value|
- acc[series_value.date] += series_value.value
- end
- end
-
- first_child = children.first
-
- summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
-
- TimeSeries.new(summed_series, favorable_direction: first_child&.series&.favorable_direction || "up")
- end
-
- def series=(series)
- raise "Cannot set series on a non-leaf node" unless is_value_node?
-
- _series = series || TimeSeries.new([])
-
- raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
- raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
- @series = _series
- end
-
- def value_nodes
- return [ self ] unless value.nil?
- children.flat_map { |child| child.value_nodes }
- end
-
- def empty?
- value_nodes.empty?
- end
-
- def percent_of_total
- return 100 if parent.nil? || parent.sum.zero?
-
- ((sum / parent.sum) * 100).round(1)
- end
-
- def add_child_group(name, currency = Money.default_currency)
- raise "Cannot add subgroup to node with a value" if is_value_node?
- child = self.class.new(name, currency)
- child.parent = self
- @children << child
- child
- end
-
- def add_value_node(original, value, series = nil)
- raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
- child = self.class.new(original.name)
- child.original = original
- child.value = value
- child.series = series
- child.parent = self
- @children << child
- child
- end
-
- def value=(value)
- raise "Cannot set value on a non-leaf node" unless is_leaf_node?
- raise "Value must be an instance of Money" unless value.is_a?(Money)
- @value = value
- @currency = value.currency
- end
-
- def is_leaf_node?
- children.empty?
- end
-
- def is_value_node?
- value.present?
- end
-
- private
- def can_add_value_node?
- return false if is_value_node?
- children.empty? || children.all?(&:is_value_node?)
- end
- end
diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb
index 70f20457..6ba19540 100644
--- a/app/models/vehicle.rb
+++ b/app/models/vehicle.rb
@@ -12,15 +12,21 @@ class Vehicle < ApplicationRecord
end
def trend
- TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
+ Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
- def color
- "#F23E94"
- end
+ class << self
+ def color
+ "#F23E94"
+ end
- def icon
- "car-front"
+ def icon
+ "car-front"
+ end
+
+ def classification
+ "asset"
+ end
end
private
diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb
index 0007f2c6..4bdf42ae 100644
--- a/app/views/account/entries/_entry.html.erb
+++ b/app/views/account/entries/_entry.html.erb
@@ -1,5 +1,4 @@
-<%# locals: (entry:, selectable: true, balance_trend: nil) %>
+<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
-<%= turbo_frame_tag dom_id(entry) do %>
- <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, balance_trend: } %>
-<% end %>
+<%= render partial: entry.entryable.to_partial_path,
+ locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx } %>
diff --git a/app/views/account/entries/_entry_group.html.erb b/app/views/account/entries/_entry_group.html.erb
index beec5893..7a08bcf7 100644
--- a/app/views/account/entries/_entry_group.html.erb
+++ b/app/views/account/entries/_entry_group.html.erb
@@ -1,13 +1,12 @@
-<%# locals: (date:, entries:, content:, selectable:, totals: false) %>
+<%# locals: (date:, entries:, content:, totals: false) %>
+
- <% if selectable %>
<%= check_box_tag "#{date}_entries_selection",
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
id: "selection_entry_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
- <% end %>
<%= tag.span I18n.l(date, format: :long) %>
@@ -22,7 +21,7 @@
<% end %>
-
diff --git a/app/views/account/entries/_loading.html.erb b/app/views/account/entries/_loading.html.erb
index 7436f568..e5496bb7 100644
--- a/app/views/account/entries/_loading.html.erb
+++ b/app/views/account/entries/_loading.html.erb
@@ -1,4 +1,4 @@
-
+
<%= tag.p t(".loading"), class: "text-secondary animate-pulse text-sm" %>
diff --git a/app/views/account/holdings/_cash.html.erb b/app/views/account/holdings/_cash.html.erb
index 0619df7f..235b5128 100644
--- a/app/views/account/holdings/_cash.html.erb
+++ b/app/views/account/holdings/_cash.html.erb
@@ -14,7 +14,7 @@
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
- <%= render "shared/progress_circle", progress: cash_weight, text_class: "text-blue-500" %>
+ <%= render "shared/progress_circle", progress: cash_weight %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
diff --git a/app/views/account/holdings/_holding.html.erb b/app/views/account/holdings/_holding.html.erb
index e07fcaac..e0aca801 100644
--- a/app/views/account/holdings/_holding.html.erb
+++ b/app/views/account/holdings/_holding.html.erb
@@ -18,7 +18,7 @@
<% if holding.weight %>
- <%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
+ <%= render "shared/progress_circle", progress: holding.weight %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "--", class: "text-secondary mb-5" %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb
index 3c52da53..a4dfeb4d 100644
--- a/app/views/account/holdings/index.html.erb
+++ b/app/views/account/holdings/index.html.erb
@@ -1,5 +1,5 @@
<%= turbo_frame_tag dom_id(@account, "holdings") do %>
-
+
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_trade_path(account_id: @account.id),
@@ -20,7 +20,7 @@
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
-
+
<% if @account.current_holdings.any? %>
<%= render "account/holdings/cash", account: @account %>
<%= render "account/holdings/ruler" %>
diff --git a/app/views/account/trades/_selection_bar.html.erb b/app/views/account/trades/_selection_bar.html.erb
deleted file mode 100644
index 78d69c45..00000000
--- a/app/views/account/trades/_selection_bar.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
- <%= check_box_tag "entry_selection", 1, true, class: "checkbox checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
-
-
-
-
-
- <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
-
- <% end %>
-
-
diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb
index 0f0f54af..3397dfa7 100644
--- a/app/views/account/trades/_trade.html.erb
+++ b/app/views/account/trades/_trade.html.erb
@@ -1,52 +1,50 @@
-<%# locals: (entry:, selectable: true, balance_trend: nil) %>
+<%# locals: (entry:, balance_trend: nil, **) %>
-<% trade, account = entry.account_trade, entry.account %>
+<% trade = entry.entryable %>
-
text-sm font-medium p-4">
-
- <% if selectable %>
- <%= check_box_tag dom_id(entry, "selection"),
+<%= turbo_frame_tag dom_id(entry) do %>
+ <%= turbo_frame_tag dom_id(trade) do %>
+
text-sm font-medium p-4">
+
+ <%= check_box_tag dom_id(entry, "selection"),
class: "checkbox checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
- <% end %>
-
- <%= tag.div class: ["flex items-center gap-2"] do %>
-
- <%= entry.display_name.first.upcase %>
-
+
+ <%= tag.div class: ["flex items-center gap-2"] do %>
+
+ <%= entry.display_name.first.upcase %>
+
-
- <% if entry.new_record? %>
- <%= content_tag :p, entry.display_name %>
- <% else %>
- <%= link_to entry.display_name,
+
+ <%= link_to entry.display_name,
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
+
<% end %>
- <% end %>
-
-
+
-
- <%= render "categories/badge", category: trade_category %>
-
+
+ <%= render "categories/badge", category: trade_category %>
+
-
- <%= content_tag :p,
+
+ <%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>
-
-
-
- <% if balance_trend&.trend %>
-
- <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-primary" %>
- <% else %>
- <%= tag.p "--", class: "font-medium text-sm text-subdued" %>
- <% end %>
-
-
+
+
+ <% if balance_trend&.trend %>
+
+ <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-primary" %>
+
+ <% else %>
+ <%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
+ <% end %>
+
+
+ <% end %>
+<% end %>
diff --git a/app/views/account/trades/index.html.erb b/app/views/account/trades/index.html.erb
deleted file mode 100644
index 4427aba1..00000000
--- a/app/views/account/trades/index.html.erb
+++ /dev/null
@@ -1,42 +0,0 @@
-<%= turbo_frame_tag dom_id(@account, "trades") do %>
-
" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
-
-
<%= t(".trades") %>
- <%= link_to new_account_trade_path(@account),
- id: dom_id(@account, "new_trade"),
- class: "flex gap-1 font-medium items-center bg-gray-50 text-primary p-2 rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon("plus", class: "w-5 h-5 text-primary") %>
- <%= t(".new") %>
- <% end %>
-
-
-
-
- <%= check_box_tag "selection_entry",
- class: "checkbox checkbox--light",
- data: { action: "bulk-select#togglePageSelection" } %>
- <%= tag.p t(".trade") %>
-
-
- <%= tag.p t(".type"), class: "col-span-3 justify-self-end" %>
- <%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %>
-
-
-
-
- <%= render "selection_bar" %>
-
-
- <% if @entries.empty? %>
-
<%= t(".no_trades") %>
- <% else %>
-
- <%= entries_by_date(@entries) do |entries, _transfers| %>
- <%= render partial: "account/trades/trade", collection: entries, as: :entry %>
- <% end %>
-
- <% end %>
-
-
-<% end %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb
index 77eb0134..8d589733 100644
--- a/app/views/account/transactions/_transaction.html.erb
+++ b/app/views/account/transactions/_transaction.html.erb
@@ -1,75 +1,98 @@
-<%# locals: (entry:, selectable: true, balance_trend: nil) %>
+<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
-
">
-
">
- <% if selectable %>
- <%= check_box_tag dom_id(entry, "selection"),
- disabled: entry.entryable.transfer?,
- class: "checkbox checkbox--light",
- data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
- <% end %>
+<% transaction = entry.entryable %>
-
- <%= content_tag :div, class: ["flex items-center gap-2"] do %>
- <% if entry.entryable.merchant&.icon_url %>
- <%= image_tag entry.entryable.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %>
- <% else %>
- <%= render "shared/circle_logo", name: entry.display_name, size: "sm" %>
- <% end %>
+<%= turbo_frame_tag dom_id(entry) do %>
+ <%= turbo_frame_tag dom_id(transaction) do %>
+
">
-
-
-
- <% if entry.new_record? %>
- <%= content_tag :p, entry.display_name %>
- <% else %>
- <%= link_to entry.entryable.transfer? ? entry.entryable.transfer.name : entry.display_name,
- entry.entryable.transfer? ? transfer_path(entry.entryable.transfer) : account_entry_path(entry),
- data: { turbo_frame: "drawer", turbo_prefetch: false },
- class: "hover:underline hover:text-gray-800" %>
- <% end %>
+
">
+ <%= check_box_tag dom_id(entry, "selection"),
+ disabled: transaction.transfer?,
+ class: "checkbox checkbox--light",
+ data: {
+ id: entry.id,
+ "bulk-select-target": "row",
+ action: "bulk-select#toggleRowSelection"
+ } %>
- <% if entry.excluded %>
-
(excluded from averages)">
- <%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
-
- <% end %>
+
+ <%= content_tag :div, class: ["flex items-center gap-2"] do %>
+ <% if transaction.merchant&.icon_url %>
+ <%= image_tag transaction.merchant.icon_url,
+ class: "w-6 h-6 rounded-full",
+ loading: "lazy" %>
+ <% else %>
+ <%= render "shared/circle_logo",
+ name: entry.display_name,
+ size: "sm" %>
+ <% end %>
- <% if entry.entryable.transfer? %>
- <%= render "account/transactions/transfer_match", entry: entry %>
- <% end %>
+
+
+
+ <%= link_to(
+ transaction.transfer? ? transaction.transfer.name : entry.display_name,
+ transaction.transfer? ? transfer_path(transaction.transfer) : account_entry_path(entry),
+ data: {
+ turbo_frame: "drawer",
+ turbo_prefetch: false
+ },
+ class: "hover:underline hover:text-gray-800"
+ ) %>
+
+ <% if entry.excluded %>
+ (excluded from averages)">
+ <%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
+
+ <% end %>
+
+ <% if transaction.transfer? %>
+ <%= render "account/transactions/transfer_match", transaction: transaction %>
+ <% end %>
+
+
+
+ <% if transaction.transfer? %>
+ <%= render "transfers/account_links",
+ transfer: transaction.transfer,
+ is_inflow: transaction.transfer_as_inflow.present? %>
+ <% else %>
+ <%= link_to entry.account.name,
+ account_path(entry.account, tab: "transactions", focused_record_id: entry.id),
+ data: { turbo_frame: "_top" },
+ class: "hover:underline" %>
+ <% end %>
+
+
+ <% end %>
+
+
-
- <% if entry.entryable.transfer? %>
- <%= render "transfers/account_links", transfer: entry.entryable.transfer, is_inflow: entry.entryable.transfer_as_inflow.present? %>
- <% else %>
- <%= link_to entry.account.name, account_path(entry.account, tab: "transactions", focused_record_id: entry.id), data: { turbo_frame: "_top" }, class: "hover:underline" %>
- <% end %>
-
-
+
+ <%= render "account/transactions/transaction_category", transaction: transaction %>
+
+
+
+ <%= content_tag :p,
+ transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
+ class: ["text-green-600": entry.amount.negative?] %>
+
+
+ <% if balance_trend %>
+
+ <% if balance_trend.trend %>
+ <%= tag.p format_money(balance_trend.trend.current),
+ class: "font-medium text-sm text-primary" %>
+ <% else %>
+ <%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
+ <% end %>
<% end %>
-
-
-
- <%= render "account/transactions/transaction_category", entry: entry %>
-
-
-
- <%= content_tag :p,
- format_money(-entry.amount_money),
- class: ["text-green-600": entry.amount.negative?] %>
-
-
- <% if balance_trend %>
-
- <% if balance_trend.trend %>
- <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-primary" %>
- <% else %>
- <%= tag.p "--", class: "font-medium text-sm text-subdued" %>
- <% end %>
-
<% end %>
-
+<% end %>
diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb
index 5489d310..933a80fe 100644
--- a/app/views/account/transactions/_transaction_category.html.erb
+++ b/app/views/account/transactions/_transaction_category.html.erb
@@ -1,9 +1,9 @@
-<%# locals: (entry:) %>
+<%# locals: (transaction:) %>
-
">
- <% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? %>
- <%= render "categories/menu", transaction: entry.account_transaction %>
+
">
+ <% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
+ <%= render "categories/menu", transaction: transaction %>
<% else %>
- <%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %>
+ <%= render "categories/badge", category: transaction.transfer&.payment? ? payment_category : transfer_category %>
<% end %>
diff --git a/app/views/account/transactions/_transfer_match.html.erb b/app/views/account/transactions/_transfer_match.html.erb
index 18665a45..a406e7f4 100644
--- a/app/views/account/transactions/_transfer_match.html.erb
+++ b/app/views/account/transactions/_transfer_match.html.erb
@@ -1,26 +1,26 @@
-<%# locals: (entry:) %>
+<%# locals: (transaction:) %>
-
" class="flex items-center gap-1">
- <% if entry.account_transaction.transfer.confirmed? %>
-
is confirmed">
+" class="flex items-center gap-1">
+ <% if transaction.transfer.confirmed? %>
+
is confirmed">
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
- <% elsif entry.account_transaction.transfer.pending? %>
+ <% elsif transaction.transfer.pending? %>
Auto-matched
- <%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }),
+ <%= button_to transfer_path(transaction.transfer, transfer: { status: "confirmed" }),
method: :patch,
- class: "text-secondary hover:text-gray-800 flex items-center justify-center",
+ class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
title: "Confirm match" do %>
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
<% end %>
- <%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }),
+ <%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }),
method: :patch,
data: { turbo: false },
- class: "text-secondary hover:text-gray-800 flex items-center justify-center",
+ class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
title: "Reject match" do %>
<%= lucide_icon "x", class: "w-4 h-4 text-subdued hover:text-gray-600" %>
<% end %>
diff --git a/app/views/account/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb
index 7f462acf..6e7d03e3 100644
--- a/app/views/account/transactions/bulk_edit.html.erb
+++ b/app/views/account/transactions/bulk_edit.html.erb
@@ -1,7 +1,7 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>