mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
New Design System + Codebase Refresh (#1823)
Since the very first 0.1.0-alpha.1 release, we've been moving quickly to add new features to the Maybe app. In doing so, some parts of the codebase have become outdated, unnecessary, or overly-complex as a natural result of this feature prioritization. Now that "core" Maybe is complete, we're moving into a second phase of development where we'll be working hard to improve the accuracy of existing features and build additional features on top of "core". This PR is a quick overhaul of the existing codebase aimed to: - Establish the brand new and simplified dashboard view (pictured above) - Establish and move towards the conventions introduced in Cursor rules and project design overview #1788 - Consolidate layouts and improve the performance of layout queries - Organize the core models of the Maybe domain (i.e. Account::Entry, Account::Transaction, etc.) and break out specific traits of each model into dedicated concerns for better readability - Remove stale / dead code from codebase - Remove overly complex code paths in favor of simpler ones
This commit is contained in:
parent
8539ac7dec
commit
d75be2282b
278 changed files with 3428 additions and 4354 deletions
|
@ -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
|
||||
|
||||
|
|
12
app/assets/images/logomark-color.svg
Normal file
12
app/assets/images/logomark-color.svg
Normal file
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<path d="M8.39804 24.0315H4.09584C3.07641 24.0315 2.25 24.8609 2.25 25.8841C2.25 26.9072 3.07641 27.7367 4.09584 27.7367H8.39804C9.41747 27.7367 10.2439 26.9072 10.2439 25.8841C10.2439 24.8609 9.41747 24.0315 8.39804 24.0315Z" fill="#F23E94"/>
|
||||
<path d="M27.6403 27.7359H31.9425C32.9619 27.7359 33.7883 26.9065 33.7883 25.8833C33.7883 24.8601 32.9619 24.0307 31.9425 24.0307H27.6403C26.6209 24.0307 25.7945 24.8601 25.7945 25.8833C25.7945 26.9065 26.6209 27.7359 27.6403 27.7359Z" fill="#F23E94"/>
|
||||
<path d="M19.7588 24.0189H16.2567C15.2373 24.0189 14.4109 24.8483 14.4109 25.8715C14.4109 26.8947 15.2373 27.7241 16.2567 27.7241H19.7588C20.7783 27.7241 21.6047 26.8947 21.6047 25.8715C21.6047 24.8483 20.7783 24.0189 19.7588 24.0189Z" fill="#F23E94"/>
|
||||
<path d="M25.9683 22.4047H30.1112C31.1306 22.4047 31.957 21.5753 31.957 20.5521C31.957 19.529 31.1306 18.6995 30.1112 18.6995H25.9683C24.9489 18.6995 24.1225 19.529 24.1225 20.5521C24.1225 21.5753 24.9489 22.4047 25.9683 22.4047Z" fill="#6927DA"/>
|
||||
<path d="M9.99971 18.6993H5.85685C4.83742 18.6993 4.01101 19.5288 4.01101 20.5519C4.01101 21.5751 4.83742 22.4045 5.85685 22.4045H9.99971C11.0191 22.4045 11.8455 21.5751 11.8455 20.5519C11.8455 19.5288 11.0191 18.6993 9.99971 18.6993Z" fill="#6927DA"/>
|
||||
<path d="M21.0888 18.6875H14.924C13.9045 18.6875 13.0781 19.517 13.0781 20.5401C13.0781 21.5633 13.9045 22.3927 14.924 22.3927H21.0888C22.1082 22.3927 22.9346 21.5633 22.9346 20.5401C22.9346 19.517 22.1082 18.6875 21.0888 18.6875Z" fill="#6927DA"/>
|
||||
<path d="M15.5578 13.2072H7.69136C6.67193 13.2072 5.84552 14.0366 5.84552 15.0598C5.84552 16.0829 6.67193 16.9123 7.69136 16.9123H15.5578C16.5772 16.9123 17.4036 16.0829 17.4036 15.0598C17.4036 14.0366 16.5772 13.2072 15.5578 13.2072Z" fill="#1570EF"/>
|
||||
<path d="M20.9094 16.9116H28.2735C29.2929 16.9116 30.1193 16.0821 30.1193 15.059C30.1193 14.0358 29.2929 13.2064 28.2735 13.2064L20.9094 13.2064C19.89 13.2064 19.0636 14.0358 19.0636 15.059C19.0636 16.0821 19.89 16.9116 20.9094 16.9116Z" fill="#1570EF"/>
|
||||
<path d="M26.5036 7.875H22.3515C21.3321 7.875 20.5057 8.70443 20.5057 9.72759C20.5057 10.7507 21.3321 11.5802 22.3515 11.5802H26.5036C27.523 11.5802 28.3494 10.7507 28.3494 9.72759C28.3494 8.70443 27.523 7.875 26.5036 7.875Z" fill="#22CCEE"/>
|
||||
<path d="M13.6077 7.875H9.45557C8.43614 7.875 7.60973 8.70443 7.60973 9.72759C7.60973 10.7507 8.43614 11.5802 9.45557 11.5802H13.6077C14.6271 11.5802 15.4535 10.7507 15.4535 9.72759C15.4535 8.70443 14.6271 7.875 13.6077 7.875Z" fill="#22CCEE"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
|
|
25
app/controllers/accountable_sparklines_controller.rb
Normal file
25
app/controllers/accountable_sparklines_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class Category::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,7 +2,6 @@ module EntryableResource
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
layout :with_sidebar
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
|
@ -10,7 +10,7 @@ class ImportsController < ApplicationController
|
|||
def index
|
||||
@imports = Current.family.imports
|
||||
|
||||
render layout: with_sidebar
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -47,7 +47,7 @@ class MfaController < ApplicationController
|
|||
if action_name.in?(%w[verify verify_code])
|
||||
"auth"
|
||||
else
|
||||
"with_sidebar"
|
||||
"settings"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Settings::BillingsController < SettingsController
|
||||
class Settings::BillingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Settings::HostingsController < SettingsController
|
||||
class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
|
||||
def show
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Settings::PreferencesController < SettingsController
|
||||
class Settings::PreferencesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Settings::SecuritiesController < SettingsController
|
||||
class Settings::SecuritiesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
class SettingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
end
|
|
@ -1,6 +1,4 @@
|
|||
class Tag::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
module EmailConfirmationsHelper
|
||||
end
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
module ImpersonationSessionsHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module InvitationsHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module PagesHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module PropertiesHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module SecuritiesHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module Settings::BillingHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module Settings::HostingHelper
|
||||
end
|
|
@ -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]
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
module SubscriptionHelper
|
||||
end
|
|
@ -1,7 +0,0 @@
|
|||
module TagsHelper
|
||||
def null_tag
|
||||
Tag.new \
|
||||
name: "Uncategorized",
|
||||
color: Tag::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
module VehiclesHelper
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
module WebhooksHelper
|
||||
end
|
|
@ -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));
|
||||
|
|
|
@ -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 `<span class="text-xl text-gray-900 font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
|
||||
}
|
||||
|
||||
#contentDetailTemplate(datum) {
|
||||
return `
|
||||
<span class="text-xl text-gray-900 font-medium">${datum.formatted_value}</span>
|
||||
<div class="flex flex-row text-xs gap-2 items-center">
|
||||
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
|
||||
<span>${datum.label}</span>
|
||||
<span>${datum.percent_of_total}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
28
app/javascript/controllers/sidebar_controller.js
Normal file
28
app/javascript/controllers/sidebar_controller.js
Normal file
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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 `
|
||||
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
|
||||
${d3.utcFormat("%b %d, %Y")(datum.date)}
|
||||
${datum.date_formatted}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
|
@ -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"></circle>
|
||||
</svg>
|
||||
|
||||
${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
|
||||
${this._extractFormattedValue(datum.trend.current)}
|
||||
</div>
|
||||
|
||||
${
|
||||
this.usePercentSignValue ||
|
||||
datum.trend.value === 0 ||
|
||||
datum.trend.value.amount === 0
|
||||
? `
|
||||
<span style="width: 80px;"></span>
|
||||
`
|
||||
datum.trend.value === 0
|
||||
? `<span style="width: 80px;"></span>`
|
||||
: `
|
||||
<span style="color: ${this._tooltipTrendColor(datum)};">
|
||||
${this._tooltipChange(datum)} (${datum.trend.percent}%)
|
||||
<span style="color: ${datum.trend.color};">
|
||||
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
|
||||
</span>
|
||||
`
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
102
app/models/account/chartable.rb
Normal file
102
app/models/account/chartable.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
40
app/models/account/transaction/transferable.rb
Normal file
40
app/models/account/transaction/transferable.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
95
app/models/balance_sheet.rb
Normal file
95
app/models/balance_sheet.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
61
app/models/family/auto_transfer_matchable.rb
Normal file
61
app/models/family/auto_transfer_matchable.rb
Normal file
|
@ -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
|
118
app/models/income_statement.rb
Normal file
118
app/models/income_statement.rb
Normal file
|
@ -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
|
42
app/models/income_statement/base_query.rb
Normal file
42
app/models/income_statement/base_query.rb
Normal file
|
@ -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
|
41
app/models/income_statement/category_stats.rb
Normal file
41
app/models/income_statement/category_stats.rb
Normal file
|
@ -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
|
47
app/models/income_statement/family_stats.rb
Normal file
47
app/models/income_statement/family_stats.rb
Normal file
|
@ -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
|
41
app/models/income_statement/totals.rb
Normal file
41
app/models/income_statement/totals.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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
|
||||
|
|
57
app/models/series.rb
Normal file
57
app/models/series.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
94
app/models/trend.rb
Normal file
94
app/models/trend.rb
Normal file
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue