1
0
Fork 0
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:
Zach Gollwitzer 2025-02-21 11:57:59 -05:00 committed by GitHub
parent 8539ac7dec
commit d75be2282b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
278 changed files with 3428 additions and 4354 deletions

View file

@ -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

View 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

View file

@ -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;

View file

@ -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;

View file

@ -1,6 +1,4 @@
class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_holding, only: %i[show destroy]
def index

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,4 @@
class Category::DeletionsController < ApplicationController
layout :with_sidebar
before_action :set_category
before_action :set_replacement_category, only: :create

View file

@ -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

View file

@ -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

View file

@ -2,7 +2,6 @@ module EntryableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_entry, only: %i[show update destroy]
end

View file

@ -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

View file

@ -10,7 +10,7 @@ class ImportsController < ApplicationController
def index
@imports = Current.family.imports
render layout: with_sidebar
render layout: "settings"
end
def new

View file

@ -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

View file

@ -47,7 +47,7 @@ class MfaController < ApplicationController
if action_name.in?(%w[verify verify_code])
"auth"
else
"with_sidebar"
"settings"
end
end
end

View file

@ -1,5 +1,4 @@
class OnboardingsController < ApplicationController
layout "application"
before_action :set_user
before_action :load_invitation

View file

@ -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

View file

@ -1,4 +1,6 @@
class Settings::BillingsController < SettingsController
class Settings::BillingsController < ApplicationController
layout "settings"
def show
@user = Current.user
end

View file

@ -1,4 +1,6 @@
class Settings::HostingsController < SettingsController
class Settings::HostingsController < ApplicationController
layout "settings"
before_action :raise_if_not_self_hosted
def show

View file

@ -1,4 +1,6 @@
class Settings::PreferencesController < SettingsController
class Settings::PreferencesController < ApplicationController
layout "settings"
def show
@user = Current.user
end

View file

@ -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)

View file

@ -1,4 +1,6 @@
class Settings::SecuritiesController < SettingsController
class Settings::SecuritiesController < ApplicationController
layout "settings"
def show
end
end

View file

@ -1,3 +0,0 @@
class SettingsController < ApplicationController
layout :with_sidebar
end

View file

@ -1,6 +1,4 @@
class Tag::DeletionsController < ApplicationController
layout :with_sidebar
before_action :set_tag
before_action :set_replacement_tag, only: :create

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,4 @@
class TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: %i[destroy show update]
def new

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -1,2 +0,0 @@
module EmailConfirmationsHelper
end

View file

@ -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

View file

@ -1,2 +0,0 @@
module ImpersonationSessionsHelper
end

View file

@ -1,2 +0,0 @@
module InvitationsHelper
end

View file

@ -1,2 +0,0 @@
module PagesHelper
end

View file

@ -1,2 +0,0 @@
module PropertiesHelper
end

View file

@ -1,2 +0,0 @@
module SecuritiesHelper
end

View file

@ -1,2 +0,0 @@
module Settings::BillingHelper
end

View file

@ -1,2 +0,0 @@
module Settings::HostingHelper
end

View file

@ -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]

View file

@ -1,2 +0,0 @@
module SubscriptionHelper
end

View file

@ -1,7 +0,0 @@
module TagsHelper
def null_tag
Tag.new \
name: "Uncategorized",
color: Tag::UNCATEGORIZED_COLOR
end
end

View file

@ -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

View file

@ -1,2 +0,0 @@
module VehiclesHelper
end

View file

@ -1,2 +0,0 @@
module WebhooksHelper
end

View file

@ -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));

View file

@ -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;
}
}

View 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(),
});
}
}

View file

@ -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);
}
});

View file

@ -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);
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = {})

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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|

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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
View 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