Compare commits
15 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c729dc33 | ||
|
|
de9723d63a | ||
|
|
eef4c2643b | ||
|
|
359bceb58e | ||
|
|
e856691c86 | ||
|
|
4433488562 | ||
|
|
37ae51f68a | ||
|
|
793a6027a3 | ||
|
|
4d20b5f2d4 | ||
|
|
7966c44d7f | ||
|
|
30b2ff7aa6 | ||
|
|
f85fdba366 | ||
|
|
0cb4e968a0 | ||
|
|
8ebf18e04d | ||
|
|
0c1ff00c1e |
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
25
.rubocop.yml
25
.rubocop.yml
@@ -1,12 +1,15 @@
|
||||
# Omakase Ruby styling for Rails
|
||||
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
inherit_gem:
|
||||
rubocop-rails-omakase: rubocop.yml
|
||||
|
||||
Layout/IndentationWidth:
|
||||
Enabled: true
|
||||
|
||||
# Overwrite or add rules to create your own house style
|
||||
#
|
||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||
# Layout/SpaceInsideArrayLiteralBrackets:
|
||||
# Enabled: false
|
||||
Layout/ElseAlignment:
|
||||
Enabled: false
|
||||
Layout/EndAlignment:
|
||||
Enabled: false
|
||||
Layout/IndentationStyle:
|
||||
EnforcedStyle: spaces
|
||||
IndentationWidth: 2
|
||||
|
||||
Layout/IndentationConsistency:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceInsidePercentLiteralDelimiters:
|
||||
Enabled: true
|
||||
4
Gemfile
4
Gemfile
@@ -42,12 +42,12 @@ gem "inline_svg"
|
||||
gem "octokit"
|
||||
gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||
gem "csv"
|
||||
gem "redcarpet"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
gem "brakeman", require: false
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "i18n-tasks"
|
||||
|
||||
44
Gemfile.lock
44
Gemfile.lock
@@ -82,16 +82,16 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.961.0)
|
||||
aws-sdk-core (3.201.3)
|
||||
aws-partitions (1.965.0)
|
||||
aws-sdk-core (3.201.5)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.157.0)
|
||||
aws-sdk-s3 (1.158.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -110,7 +110,7 @@ GEM
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
brakeman (6.2.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -166,12 +166,12 @@ GEM
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
fugit (1.11.0)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.1.1)
|
||||
good_job (4.2.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -234,7 +234,7 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.24.1)
|
||||
minitest (5.25.1)
|
||||
mocha (2.4.5)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
@@ -272,7 +272,7 @@ GEM
|
||||
racc
|
||||
pg (1.5.7)
|
||||
prism (0.30.0)
|
||||
propshaft (0.9.0)
|
||||
propshaft (0.9.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -371,12 +371,12 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.12)
|
||||
ruby-lsp (0.17.13)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.12)
|
||||
ruby-lsp-rails (0.3.13)
|
||||
ruby-lsp (>= 0.17.12, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
@@ -394,10 +394,10 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.18.2)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.18.2)
|
||||
sentry-ruby (5.18.2)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sentry-ruby (5.19.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -407,23 +407,23 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11518)
|
||||
sorbet-runtime (0.5.11528)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.7.2)
|
||||
tailwindcss-rails (2.7.3)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.2-aarch64-linux)
|
||||
tailwindcss-rails (2.7.3-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.2-arm-linux)
|
||||
tailwindcss-rails (2.7.3-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.2-arm64-darwin)
|
||||
tailwindcss-rails (2.7.3-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.2-x86_64-darwin)
|
||||
tailwindcss-rails (2.7.3-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.2-x86_64-linux)
|
||||
tailwindcss-rails (2.7.3-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: bin/rails server -b 0.0.0.0
|
||||
web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
|
||||
@@ -2,7 +2,7 @@ class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
|
||||
@@ -8,7 +8,7 @@ class Account::TradesController < ApplicationController
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ])
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -2,7 +2,7 @@ class AccountsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
before_action :set_account, only: %i[edit show destroy sync update]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@@ -25,6 +25,8 @@ class AccountsController < ApplicationController
|
||||
def new
|
||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
||||
|
||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_category, only: %i[edit update]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
|
||||
@@ -6,17 +6,17 @@ class Category::DropdownsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
def categories_scope
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,27 +13,27 @@ module Authentication
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user!
|
||||
if user = User.find_by(id: session[:user_id])
|
||||
Current.user = user
|
||||
else
|
||||
redirect_to new_session_url
|
||||
def authenticate_user!
|
||||
if user = User.find_by(id: session[:user_id])
|
||||
Current.user = user
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def login(user)
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
def login(user)
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
|
||||
def logout
|
||||
Current.user = nil
|
||||
reset_session
|
||||
end
|
||||
def logout
|
||||
Current.user = nil
|
||||
reset_session
|
||||
end
|
||||
|
||||
def set_last_login_at
|
||||
Current.user.update(last_login_at: DateTime.now)
|
||||
end
|
||||
def set_last_login_at
|
||||
Current.user.update(last_login_at: DateTime.now)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module Filterable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "ostruct"
|
||||
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, except: %i[ index new create ]
|
||||
before_action :set_import, except: %i[index new create]
|
||||
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
@@ -40,7 +40,7 @@ class ImportsController < ApplicationController
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_csv_str = import_params[:raw_csv_str].read
|
||||
@import.raw_file_str = import_params[:raw_file_str].read
|
||||
rescue NoMethodError
|
||||
flash.now[:alert] = "Please select a file to upload"
|
||||
render :load, status: :unprocessable_entity and return
|
||||
@@ -113,6 +113,6 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def import_params(permitted_mappings = nil)
|
||||
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[ new create ]
|
||||
before_action :set_institution, except: %i[new create]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
@@ -31,11 +31,11 @@ class MerchantsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
layout "auth"
|
||||
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
before_action :set_user_by_token, only: %i[edit update]
|
||||
|
||||
def new
|
||||
end
|
||||
@@ -33,12 +33,12 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
|
||||
end
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
|
||||
end
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
|
||||
end
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
|
||||
end
|
||||
end
|
||||
|
||||
41
app/controllers/properties_controller.rb
Normal file
41
app/controllers/properties_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class PropertiesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params)
|
||||
@account.sync_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:year_built,
|
||||
:area_unit,
|
||||
:area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -28,17 +28,17 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
unless InviteCode.claim! params[:user][:invite_code]
|
||||
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
unless InviteCode.claim! params[:user][:invite_code]
|
||||
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,8 +32,8 @@ class Settings::ProfilesController < SettingsController
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class TagsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
before_action :set_tag, only: %i[edit update]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
|
||||
42
app/controllers/vehicles_controller.rb
Normal file
42
app/controllers/vehicles_controller.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class VehiclesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params)
|
||||
@account.sync_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:make,
|
||||
:model,
|
||||
:year,
|
||||
:mileage_value,
|
||||
:mileage_unit
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -23,13 +23,39 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
|
||||
# Eventually, we'll have an accountable form for each type of accountable, so
|
||||
# this helper is a convenience for now to reuse common logic in the accounts controller
|
||||
def new_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
properties_path
|
||||
when "Vehicle"
|
||||
vehicles_path
|
||||
else
|
||||
accounts_path
|
||||
end
|
||||
end
|
||||
|
||||
def edit_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
property_path(account)
|
||||
when "Vehicle"
|
||||
vehicle_path(account)
|
||||
else
|
||||
account_path(account)
|
||||
end
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
|
||||
|
||||
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
|
||||
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
|
||||
2
app/helpers/properties_helper.rb
Normal file
2
app/helpers/properties_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module PropertiesHelper
|
||||
end
|
||||
2
app/helpers/vehicles_helper.rb
Normal file
2
app/helpers/vehicles_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module VehiclesHelper
|
||||
end
|
||||
@@ -2,6 +2,11 @@ import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
static values = {
|
||||
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
|
||||
acceptedExtension: String, // "csv"
|
||||
unacceptableTypeLabel: String, // "Only CSV files are allowed."
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.submitTarget.disabled = true
|
||||
@@ -30,12 +35,12 @@ export default class extends Controller {
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && this._isCSVFile(file)) {
|
||||
if (file && this._formatAcceptable(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "Only CSV files are allowed."
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +62,7 @@ export default class extends Controller {
|
||||
if (file) {
|
||||
if (file.size > fileSizeLimit) {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,10 +85,9 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
_isCSVFile(file) {
|
||||
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
|
||||
_formatAcceptable(file) {
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return acceptedTypes.includes(file.type) || extension === "csv"
|
||||
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
@@ -5,12 +5,12 @@ class ApplicationMailer < ActionMailer::Base
|
||||
|
||||
private
|
||||
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,8 @@ class Account < ApplicationRecord
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :accountable
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
@@ -51,27 +53,28 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
transaction do
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created
|
||||
account = new(attributes)
|
||||
|
||||
# Always build the initial valuation
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
# Always initialize an account with a valuation entry to begin tracking value history
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Account::Balance < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable
|
||||
|
||||
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 :chronological, -> { order(:date) }
|
||||
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 :chronological, -> { order(:date) }
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ income expense buy sell interest transfer_in transfer_out ].freeze
|
||||
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
|
||||
TYPES = %w[Account::Valuation Account::Transaction Account::Trade]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
|
||||
@@ -14,9 +14,12 @@ class Account::Holding < ApplicationRecord
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :name, to: :security
|
||||
delegate :ticker, to: :security
|
||||
|
||||
def name
|
||||
security.name || ticker
|
||||
end
|
||||
|
||||
def weight
|
||||
return nil unless amount
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ buy sell ].freeze
|
||||
TYPES = %w[buy sell].freeze
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class Account::Transaction < ApplicationRecord
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[ categories merchants ]
|
||||
%i[categories merchants]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Account::TransactionBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ income expense interest transfer_in transfer_out ].freeze
|
||||
TYPES = %w[income expense interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :amount, :date, :account, :transfer_account_id
|
||||
|
||||
|
||||
24
app/models/address.rb
Normal file
24
app/models/address.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Address < ApplicationRecord
|
||||
belongs_to :addressable, polymorphic: true
|
||||
|
||||
validates :line1, :locality, presence: true
|
||||
validates :postal_code, presence: true, if: :postal_code_required?
|
||||
|
||||
def to_s
|
||||
I18n.t("address.format",
|
||||
line1: line1,
|
||||
line2: line2,
|
||||
county: county,
|
||||
locality: locality,
|
||||
region: region,
|
||||
country: country,
|
||||
postal_code: postal_code
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def postal_code_required?
|
||||
country.in?(%w[US CA GB])
|
||||
end
|
||||
end
|
||||
@@ -47,7 +47,7 @@ class Category < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
end
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module Accountable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ASSET_TYPES = %w[ Depository Investment Crypto Property Vehicle OtherAsset ]
|
||||
LIABILITY_TYPES = %w[ CreditCard Loan OtherLiability ]
|
||||
ASSET_TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset]
|
||||
LIABILITY_TYPES = %w[CreditCard Loan OtherLiability]
|
||||
TYPES = ASSET_TYPES + LIABILITY_TYPES
|
||||
|
||||
def self.from_type(type)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
module Monetizable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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)
|
||||
end
|
||||
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)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,7 +46,8 @@ class Family < ApplicationRecord
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.group("id")
|
||||
.group("accounts.id")
|
||||
.having("SUM(ABS(account_entries.amount)) > 0")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Import < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validate :raw_csv_must_be_parsable
|
||||
validate :raw_file_must_be_parsable
|
||||
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
|
||||
|
||||
before_save :initialize_csv, if: :should_initialize_csv?
|
||||
@@ -19,7 +19,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def loaded?
|
||||
raw_csv_str.present?
|
||||
raw_file_str.present?
|
||||
end
|
||||
|
||||
def configured?
|
||||
@@ -88,16 +88,16 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_raw_csv
|
||||
return nil if raw_csv_str.nil?
|
||||
Import::Csv.new(raw_csv_str, col_sep:)
|
||||
return nil if raw_file_str.nil?
|
||||
Import::Csv.new(raw_file_str, col_sep:)
|
||||
end
|
||||
|
||||
def should_initialize_csv?
|
||||
raw_csv_str_changed? || column_mappings_changed?
|
||||
raw_file_str_changed? || column_mappings_changed?
|
||||
end
|
||||
|
||||
def initialize_csv
|
||||
generated_csv = generate_normalized_csv(raw_csv_str)
|
||||
generated_csv = generate_normalized_csv(raw_file_str)
|
||||
self.normalized_csv_str = generated_csv.table.to_s
|
||||
end
|
||||
|
||||
@@ -175,12 +175,12 @@ class Import < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def raw_csv_must_be_parsable
|
||||
def raw_file_must_be_parsable
|
||||
begin
|
||||
CSV.parse(raw_csv_str || "", col_sep:)
|
||||
CSV.parse(raw_file_str || "", col_sep:)
|
||||
rescue CSV::MalformedCSVError
|
||||
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_csv_str.invalid_csv_format')
|
||||
errors.add(:raw_csv_str, :invalid_csv_format)
|
||||
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_file_str.invalid_csv_format')
|
||||
errors.add(:raw_file_str, :invalid_csv_format)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,8 +11,8 @@ class Import::Csv
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
||||
raw_csv = self.parse_csv(raw_csv_str, col_sep:)
|
||||
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
||||
raw_csv = self.parse_csv(raw_file_str, col_sep:)
|
||||
|
||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
|
||||
raw_csv.each do |row|
|
||||
|
||||
@@ -16,10 +16,10 @@ class InviteCode < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
loop do
|
||||
self.token = SecureRandom.hex(4)
|
||||
break token unless self.class.exists?(token: token)
|
||||
def generate_token
|
||||
loop do
|
||||
self.token = SecureRandom.hex(4)
|
||||
break token unless self.class.exists?(token: token)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
20
app/models/measurement.rb
Normal file
20
app/models/measurement.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class Measurement
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :value, :unit
|
||||
|
||||
VALID_UNITS = %w[sqft sqm mi km]
|
||||
|
||||
validates :unit, inclusion: { in: VALID_UNITS }
|
||||
validates :value, presence: true
|
||||
|
||||
def initialize(value, unit)
|
||||
@value = value.to_f
|
||||
@unit = unit.to_s.downcase.strip
|
||||
validate!
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{@value.to_i} #{@unit}"
|
||||
end
|
||||
end
|
||||
@@ -1,35 +1,35 @@
|
||||
class Period
|
||||
attr_reader :name, :date_range
|
||||
attr_reader :name, :date_range
|
||||
|
||||
def self.find_by_name(name)
|
||||
INDEX[name]
|
||||
end
|
||||
|
||||
def self.names
|
||||
INDEX.keys.sort
|
||||
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: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
|
||||
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
|
||||
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
|
||||
]
|
||||
|
||||
INDEX = BUILTIN.index_by(&:name)
|
||||
|
||||
BUILTIN.each do |period|
|
||||
define_singleton_method(period.name) do
|
||||
period
|
||||
end
|
||||
def self.find_by_name(name)
|
||||
INDEX[name]
|
||||
end
|
||||
|
||||
def self.names
|
||||
INDEX.keys.sort
|
||||
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: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
|
||||
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
|
||||
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
|
||||
]
|
||||
|
||||
INDEX = BUILTIN.index_by(&:name)
|
||||
|
||||
BUILTIN.each do |period|
|
||||
define_singleton_method(period.name) do
|
||||
period
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
class Property < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
has_one :address, as: :addressable, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :address
|
||||
|
||||
attribute :area_unit, :string, default: "sqft"
|
||||
|
||||
def area
|
||||
Measurement.new(area_value, area_unit) if area_value.present?
|
||||
end
|
||||
|
||||
def purchase_price
|
||||
first_valuation_amount
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
|
||||
end
|
||||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,11 +16,11 @@ class Provider::Github
|
||||
latest_commit = Octokit.branch(repo, branch)
|
||||
|
||||
release_info = if latest_release
|
||||
{
|
||||
version: latest_version,
|
||||
url: latest_release.html_url,
|
||||
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
|
||||
}
|
||||
{
|
||||
version: latest_version,
|
||||
url: latest_release.html_url,
|
||||
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
|
||||
}
|
||||
end
|
||||
|
||||
commit_info = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TimeSeries
|
||||
DIRECTIONS = %w[ up down ].freeze
|
||||
DIRECTIONS = %w[up down].freeze
|
||||
|
||||
attr_reader :values, :favorable_direction
|
||||
|
||||
|
||||
@@ -74,18 +74,18 @@ class User < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def last_user_in_family?
|
||||
family.users.count == 1
|
||||
end
|
||||
|
||||
def deactivated_email
|
||||
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||
end
|
||||
|
||||
def profile_image_size
|
||||
if profile_image.attached? && profile_image.byte_size > 5.megabytes
|
||||
# i18n-tasks-use t('activerecord.errors.models.user.attributes.profile_image.invalid_file_size')
|
||||
errors.add(:profile_image, :invalid_file_size, max_megabytes: 5)
|
||||
def last_user_in_family?
|
||||
family.users.count == 1
|
||||
end
|
||||
|
||||
def deactivated_email
|
||||
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||
end
|
||||
|
||||
def profile_image_size
|
||||
if profile_image.attached? && profile_image.byte_size > 5.megabytes
|
||||
# i18n-tasks-use t('activerecord.errors.models.user.attributes.profile_image.invalid_file_size')
|
||||
errors.add(:profile_image, :invalid_file_size, max_megabytes: 5)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,100 +3,100 @@
|
||||
attr_reader :name, :children, :value, :currency
|
||||
|
||||
def initialize(name, currency = Money.default_currency)
|
||||
@name = name
|
||||
@currency = Money::Currency.new(currency)
|
||||
@children = []
|
||||
@name = name
|
||||
@currency = Money::Currency.new(currency)
|
||||
@children = []
|
||||
end
|
||||
|
||||
def sum
|
||||
return value if is_value_node?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
children.sum(&:sum)
|
||||
return value if is_value_node?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
children.sum(&:sum)
|
||||
end
|
||||
|
||||
def avg
|
||||
return value if is_value_node?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
leaf_values = value_nodes.map(&:value)
|
||||
leaf_values.compact.sum / leaf_values.compact.size
|
||||
return value if is_value_node?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
leaf_values = value_nodes.map(&:value)
|
||||
leaf_values.compact.sum / leaf_values.compact.size
|
||||
end
|
||||
|
||||
def series
|
||||
return @series if is_value_node?
|
||||
return @series if is_value_node?
|
||||
|
||||
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
|
||||
child.series.values.each do |series_value|
|
||||
acc[series_value.date] += series_value.value
|
||||
end
|
||||
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
|
||||
child.series.values.each do |series_value|
|
||||
acc[series_value.date] += series_value.value
|
||||
end
|
||||
end
|
||||
|
||||
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
|
||||
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
|
||||
|
||||
TimeSeries.new(summed_series)
|
||||
TimeSeries.new(summed_series)
|
||||
end
|
||||
|
||||
def series=(series)
|
||||
raise "Cannot set series on a non-leaf node" unless is_value_node?
|
||||
raise "Cannot set series on a non-leaf node" unless is_value_node?
|
||||
|
||||
_series = series || TimeSeries.new([])
|
||||
_series = series || TimeSeries.new([])
|
||||
|
||||
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
|
||||
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
|
||||
@series = _series
|
||||
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
|
||||
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
|
||||
@series = _series
|
||||
end
|
||||
|
||||
def value_nodes
|
||||
return [ self ] unless value.nil?
|
||||
children.flat_map { |child| child.value_nodes }
|
||||
return [ self ] unless value.nil?
|
||||
children.flat_map { |child| child.value_nodes }
|
||||
end
|
||||
|
||||
def empty?
|
||||
value_nodes.empty?
|
||||
value_nodes.empty?
|
||||
end
|
||||
|
||||
def percent_of_total
|
||||
return 100 if parent.nil? || parent.sum.zero?
|
||||
return 100 if parent.nil? || parent.sum.zero?
|
||||
|
||||
((sum / parent.sum) * 100).round(1)
|
||||
((sum / parent.sum) * 100).round(1)
|
||||
end
|
||||
|
||||
def add_child_group(name, currency = Money.default_currency)
|
||||
raise "Cannot add subgroup to node with a value" if is_value_node?
|
||||
child = self.class.new(name, currency)
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
raise "Cannot add subgroup to node with a value" if is_value_node?
|
||||
child = self.class.new(name, currency)
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
end
|
||||
|
||||
def add_value_node(original, value, series = nil)
|
||||
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
|
||||
child = self.class.new(original.name)
|
||||
child.original = original
|
||||
child.value = value
|
||||
child.series = series
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
|
||||
child = self.class.new(original.name)
|
||||
child.original = original
|
||||
child.value = value
|
||||
child.series = series
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
end
|
||||
|
||||
def value=(value)
|
||||
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
|
||||
raise "Value must be an instance of Money" unless value.is_a?(Money)
|
||||
@value = value
|
||||
@currency = value.currency
|
||||
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
|
||||
raise "Value must be an instance of Money" unless value.is_a?(Money)
|
||||
@value = value
|
||||
@currency = value.currency
|
||||
end
|
||||
|
||||
def is_leaf_node?
|
||||
children.empty?
|
||||
children.empty?
|
||||
end
|
||||
|
||||
def is_value_node?
|
||||
value.present?
|
||||
value.present?
|
||||
end
|
||||
|
||||
private
|
||||
def can_add_value_node?
|
||||
return false if is_value_node?
|
||||
children.empty? || children.all?(&:is_value_node?)
|
||||
end
|
||||
def can_add_value_node?
|
||||
return false if is_value_node?
|
||||
children.empty? || children.all?(&:is_value_node?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
class Vehicle < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
attribute :mileage_unit, :string, default: "mi"
|
||||
|
||||
def mileage
|
||||
Measurement.new(mileage_value, mileage_unit) if mileage_value.present?
|
||||
end
|
||||
|
||||
def purchase_price
|
||||
first_valuation_amount
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
|
||||
end
|
||||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: holding.name || "H" %>
|
||||
<%= render "shared/circle_logo", name: holding.name %>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
|
||||
<% if holding.amount %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
<% else %>
|
||||
|
||||
21
app/views/accounts/_form.html.erb
Normal file
21
app/views/accounts/_form.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (account:, url:) %>
|
||||
|
||||
<%= styled_form_with model: account, url: url, scope: :account, class: "flex flex-col gap-4 justify-between grow", data: { turbo: false } do |f| %>
|
||||
<div class="grow space-y-2">
|
||||
<%= f.hidden_field :accountable_type %>
|
||||
<%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label"), autofocus: true %>
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), required: "required", default_currency: Current.family.currency %>
|
||||
|
||||
<% if account.new_record? %>
|
||||
<div class="flex items-center gap-2 mt-3 mb-6">
|
||||
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %></div>
|
||||
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance"), placeholder: 90 %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "accounts/accountables/#{permitted_accountable_partial(account.accountable_type)}", f: f %>
|
||||
</div>
|
||||
|
||||
<%= f.submit "#{account.new_record? ? "Add" : "Update"} #{account.accountable.model_name.human.downcase}" %>
|
||||
<% end %>
|
||||
3
app/views/accounts/_overview.html.erb
Normal file
3
app/views/accounts/_overview.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%# locals: (account:) %>
|
||||
|
||||
<%= render partial: "accounts/accountables/#{account.accountable_type.downcase}/overview", locals: { account: account } %>
|
||||
@@ -0,0 +1,34 @@
|
||||
<%# locals: (f:) %>
|
||||
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.fields_for :accountable do |af| %>
|
||||
<div class="flex gap-2">
|
||||
<%= af.number_field :year_built, label: t(".year_built"), placeholder: 2005 %>
|
||||
<%= af.number_field :area_value, label: t(".area_value"), placeholder: 2000 %>
|
||||
<%= af.select :area_unit,
|
||||
[["Square feet", "sqft"], ["Square meters", "sqm"]],
|
||||
{ label: t(".area_unit") } %>
|
||||
</div>
|
||||
|
||||
<%= af.fields_for :address do |address_form| %>
|
||||
<div class="flex gap-2">
|
||||
<%= address_form.text_field :line1, label: t(".line1"), placeholder: "123 Main St", required: true %>
|
||||
<%= address_form.text_field :line2, label: t(".line2"), placeholder: "Apt 1" %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<%= address_form.text_field :locality, label: t(".city"), placeholder: "Sacramento", required: true %>
|
||||
<%= address_form.text_field :region, label: t(".state"), placeholder: "CA", required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<%= address_form.text_field :postal_code, label: t(".postal_code"), placeholder: "95814" %>
|
||||
<%= address_form.text_field :country, label: t(".country"), placeholder: "USA", required: true %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<%# locals: (f:) %>
|
||||
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.fields_for :accountable do |vehicle_form| %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= vehicle_form.text_field :make, label: t(".make"), placeholder: t(".make_placeholder") %>
|
||||
<%= vehicle_form.text_field :model, label: t(".model"), placeholder: t(".model_placeholder") %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= vehicle_form.text_field :year, label: t(".year"), placeholder: t(".year_placeholder") %>
|
||||
<%= vehicle_form.text_field :mileage_value, label: t(".mileage"), placeholder: t(".mileage_placeholder") %>
|
||||
<%= vehicle_form.select :mileage_unit,
|
||||
[["Miles", "mi"], ["Kilometers", "km"]],
|
||||
{ label: t(".mileage_unit") } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
40
app/views/accounts/accountables/property/_overview.html.erb
Normal file
40
app/views/accounts/accountables/property/_overview.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<%# locals: (account:) %>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".market_value") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900"><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".purchase_price") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= account.property.purchase_price ? format_money(account.property.purchase_price) : t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm flex items-center gap-1"><%= t(".trend") %></h4>
|
||||
<div class="flex items-center gap-1" style="color: <%= account.property.trend.color %>">
|
||||
<p class="text-xl font-medium">
|
||||
<%= account.property.trend.value %>
|
||||
</p>
|
||||
|
||||
<p>(<%= account.property.trend.percent %>%)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".year_built") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= account.property.year_built || t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".living_area") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= account.property.area || t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
49
app/views/accounts/accountables/vehicle/_overview.html.erb
Normal file
49
app/views/accounts/accountables/vehicle/_overview.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<%# locals: (account:) %>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".make_model") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= [account.vehicle.make, account.vehicle.model].compact.join(" ").presence || t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".year") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= account.vehicle.year || t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm flex items-center gap-1"><%= t(".mileage") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= account.vehicle.mileage || t(".unknown") %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".purchase_price") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= format_money account.vehicle.purchase_price %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".current_price") %></h4>
|
||||
<p class="text-xl font-medium text-gray-900">
|
||||
<%= format_money account.balance_money %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-xs border border-alpha-black-25 p-4">
|
||||
<h4 class="text-gray-500 text-sm"><%= t(".trend") %></h4>
|
||||
<div class="flex items-center gap-1" style="color: <%= account.vehicle.trend.color %>">
|
||||
<p class="text-xl font-medium">
|
||||
<%= account.vehicle.trend.value %>
|
||||
</p>
|
||||
|
||||
<p>(<%= account.vehicle.trend.percent %>%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +1,3 @@
|
||||
<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
|
||||
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name") %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
||||
|
||||
<div class="relative">
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= link_to new_institution_path do %>
|
||||
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= f.submit %>
|
||||
<% end %>
|
||||
<%= render "form", account: @account, url: edit_account_form_url(@account) %>
|
||||
<% end %>
|
||||
|
||||
@@ -73,26 +73,10 @@
|
||||
<% end %>
|
||||
<span>Add <%= @account.accountable.model_name.human.downcase %></span>
|
||||
</div>
|
||||
<%= styled_form_with model: @account, url: accounts_path, scope: :account, class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } do |f| %>
|
||||
<div class="space-y-4 grow">
|
||||
<%= f.hidden_field :accountable_type %>
|
||||
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= render "accounts/accountables/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), required: "required", default_currency: Current.family.currency %>
|
||||
|
||||
<div>
|
||||
<%= check_box_tag :add_start_values, class: "maybe-checkbox maybe-checkbox--light peer mb-1" %>
|
||||
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
|
||||
|
||||
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
|
||||
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %></div>
|
||||
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||
<% end %>
|
||||
<div class="p-4 pt-1">
|
||||
<%= render "form", account: @account, url: new_account_form_url(@account) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<%= turbo_stream_from @account %>
|
||||
|
||||
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
|
||||
<div class="flex justify-between items-center">
|
||||
<header class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %>
|
||||
<h2 class="font-medium text-xl"><%= @account.name %></h2>
|
||||
<div>
|
||||
<h2 class="font-medium text-xl"><%= @account.name %></h2>
|
||||
|
||||
<% if @account.property? && @account.property.address %>
|
||||
<p class="text-gray-500"><%= @account.property.address %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %>
|
||||
@@ -43,7 +49,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if @account.highest_priority_issue %>
|
||||
<%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %>
|
||||
@@ -79,7 +85,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% selected_tab_key, selected_tab_content_path = selected_account_tab(@account).values_at(:key, :content_path) %>
|
||||
<% selected_tab = selected_account_tab(@account) %>
|
||||
<% selected_tab_key = selected_tab[:key] %>
|
||||
<% selected_tab_partial_path = selected_tab[:partial_path] %>
|
||||
<% selected_tab_route = selected_tab[:route] %>
|
||||
|
||||
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
|
||||
<% account_tabs(@account).each do |tab| %>
|
||||
@@ -88,8 +97,12 @@
|
||||
</div>
|
||||
|
||||
<div class="min-h-[800px]">
|
||||
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_content_path do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% if selected_tab_route.present? %>
|
||||
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_route do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= render selected_tab_partial_path, account: @account %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<%= styled_form_with model: @import, url: load_import_path(@import), class: "space-y-4" do |form| %>
|
||||
<%= form.text_area :raw_csv_str,
|
||||
<%= form.text_area :raw_file_str,
|
||||
rows: 10,
|
||||
required: true,
|
||||
placeholder: "Paste your CSV file contents here",
|
||||
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400" %>
|
||||
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "csv-upload" }, method: :patch, multipart: true do |form| %>
|
||||
<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "import-upload", import_upload_accepted_types_value: ["text/csv", "application/csv", ".csv"], import_upload_extension_value: "csv", import_upload_unacceptable_type_label_value: t(".allowed_filetypes") }, method: :patch, multipart: true do |form| %>
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<label for="import_raw_csv_str" class="csv-drop-box flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50" data-action="dragover->csv-upload#dragover dragleave->csv-upload#dragleave drop->csv-upload#drop">
|
||||
<label for="import_raw_file_str" class="raw-file-drop-box flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50" data-action="dragover->import-upload#dragover dragleave->import-upload#dragleave drop->import-upload#drop">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %>
|
||||
<%= form.file_field :raw_csv_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { csv_upload_target: "input", action: "change->csv-upload#addFile" } %>
|
||||
<%= form.file_field :raw_file_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { import_upload_target: "input", action: "change->import-upload#addFile" } %>
|
||||
<p class="mb-2 text-sm text-gray-500 mt-3">Drag and drop your csv file here or <span class="text-black">click to browse</span></p>
|
||||
<p class="text-xs text-gray-500">CSV (Max. 5MB)</p>
|
||||
<div class="csv-preview" data-csv-upload-target="preview"></div>
|
||||
<div class="csv-preview" data-import-upload-target="preview"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { csv_upload_target: "submit", turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { import_upload_target: "submit", turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
|
||||
<div id="template-preview" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<%= lucide_icon "file-text", class: "w-10 h-10 pt-2 text-black" %>
|
||||
<div class="flex flex-row items-center justify-center gap-0.5">
|
||||
<div><span data-csv-upload-target="filename"></span></div>
|
||||
<div><span data-csv-upload-target="filesize" class="font-semibold"></span></div>
|
||||
<div><span data-import-upload-target="filename"></span></div>
|
||||
<div><span data-import-upload-target="filesize" class="font-semibold"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,42 @@
|
||||
22
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Dynamic Render Path",
|
||||
"warning_code": 15,
|
||||
"fingerprint": "b7a59d6dd91f4d30873b271659636c7975e25b47f436b4f03900a08809af2e92",
|
||||
"check_name": "Render",
|
||||
"message": "Render path contains parameter value",
|
||||
"file": "app/views/accounts/show.html.erb",
|
||||
"line": 105,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
|
||||
"code": "render(action => selected_account_tab(Current.family.accounts.find(params[:id]))[:partial_path], { :account => Current.family.accounts.find(params[:id]) })",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "AccountsController",
|
||||
"method": "show",
|
||||
"line": 38,
|
||||
"file": "app/controllers/accounts_controller.rb",
|
||||
"rendered": {
|
||||
"name": "accounts/show",
|
||||
"file": "app/views/accounts/show.html.erb"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "accounts/show"
|
||||
},
|
||||
"user_input": "params[:id]",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
22
|
||||
],
|
||||
"note": ""
|
||||
}
|
||||
],
|
||||
"updated": "2024-08-16 10:19:50 -0400",
|
||||
"brakeman_version": "6.1.2"
|
||||
"updated": "2024-08-23 08:29:05 -0400",
|
||||
"brakeman_version": "6.2.1"
|
||||
}
|
||||
|
||||
@@ -63,5 +63,5 @@ Rails.application.configure do
|
||||
# Raise error when a before_action's only/except options reference missing actions
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
config.autoload_paths += %w[ test/support ]
|
||||
config.autoload_paths += %w[test/support]
|
||||
end
|
||||
|
||||
@@ -29,3 +29,4 @@ ignore_unused:
|
||||
- 'helpers.submit.*' # i18n-tasks does not detect used at forms
|
||||
- 'helpers.label.*' # i18n-tasks does not detect used at forms
|
||||
- 'accounts.show.sync_message_*' # messages generated in the sync ActiveJob
|
||||
- 'address.attributes.*'
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.15"
|
||||
"0.1.0-alpha.16"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
15
config/locales/models/address/en.yml
Normal file
15
config/locales/models/address/en.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
en:
|
||||
address:
|
||||
attributes:
|
||||
country: Country
|
||||
line1: Address Line 1
|
||||
line2: Address Line 2
|
||||
locality: Locality
|
||||
postal_code: Postal Code
|
||||
region: Region
|
||||
format: |-
|
||||
%{line1}
|
||||
%{line2}
|
||||
%{locality}, %{region} %{postal_code}
|
||||
%{country}
|
||||
@@ -5,5 +5,5 @@ en:
|
||||
models:
|
||||
import:
|
||||
attributes:
|
||||
raw_csv_str:
|
||||
raw_file_str:
|
||||
invalid_csv_format: is not a valid CSV format
|
||||
|
||||
@@ -1,20 +1,60 @@
|
||||
---
|
||||
en:
|
||||
accounts:
|
||||
accountables:
|
||||
property:
|
||||
area_unit: Area unit
|
||||
area_value: Area value (optional)
|
||||
city: City
|
||||
country: Country
|
||||
line1: Address line 1
|
||||
line2: Address line 2 (optional)
|
||||
overview:
|
||||
living_area: Living Area
|
||||
market_value: Market Value
|
||||
purchase_price: Purchase Price
|
||||
trend: Trend
|
||||
unknown: Unknown
|
||||
year_built: Year Built
|
||||
postal_code: Postal code (optional)
|
||||
state: State
|
||||
year_built: Year built (optional)
|
||||
vehicle:
|
||||
make: Make
|
||||
make_placeholder: Toyota
|
||||
mileage: Mileage
|
||||
mileage_placeholder: '15000'
|
||||
mileage_unit: Unit
|
||||
model: Model
|
||||
model_placeholder: Camry
|
||||
overview:
|
||||
current_price: Current Price
|
||||
make_model: Make & Model
|
||||
mileage: Mileage
|
||||
purchase_price: Purchase Price
|
||||
trend: Trend
|
||||
unknown: Unknown
|
||||
year: Year
|
||||
year: Year
|
||||
year_placeholder: '2023'
|
||||
create:
|
||||
success: New account created successfully
|
||||
destroy:
|
||||
success: Account deleted successfully
|
||||
edit:
|
||||
balance: Balance
|
||||
edit: Edit %{account}
|
||||
institution: Financial institution
|
||||
name: Name
|
||||
ungrouped: "(none)"
|
||||
empty:
|
||||
empty_message: Add an account either via connection, importing or entering manually.
|
||||
new_account: New account
|
||||
no_accounts: No accounts yet
|
||||
form:
|
||||
balance: Current balance
|
||||
institution: Financial institution
|
||||
name_label: Account name
|
||||
name_placeholder: Example account name
|
||||
start_balance: Start balance (optional)
|
||||
start_date: Start date (optional)
|
||||
ungrouped: "(none)"
|
||||
header:
|
||||
accounts: Accounts
|
||||
manage: Manage accounts
|
||||
@@ -36,17 +76,8 @@ en:
|
||||
institutionless_accounts:
|
||||
other_accounts: Other accounts
|
||||
new:
|
||||
balance: Current balance
|
||||
institution: Financial institution
|
||||
name:
|
||||
label: Account name
|
||||
placeholder: Example account name
|
||||
optional_start_balance_message: Add a start balance for this account
|
||||
select_accountable_type: What would you like to add?
|
||||
start_balance: Start balance (optional)
|
||||
start_date: Start date (optional)
|
||||
title: Add an account
|
||||
ungrouped: "(none)"
|
||||
show:
|
||||
cash: Cash
|
||||
confirm_accept: Delete "%{name}"
|
||||
@@ -60,6 +91,7 @@ en:
|
||||
holdings: Holdings
|
||||
import: Import transactions
|
||||
no_change: No change
|
||||
overview: Overview
|
||||
sync_message_missing_rates: Since exchange rates haven't been synced, balance
|
||||
graphs may not reflect accurate values.
|
||||
sync_message_unknown_error: An error has occurred during the sync.
|
||||
|
||||
@@ -40,6 +40,7 @@ en:
|
||||
"inflow" (income)
|
||||
requirement3: Can have 0 or more tags separated by |
|
||||
csv_upload:
|
||||
allowed_filetypes: Only CSV files are allowed.
|
||||
confirm_accept: Yep, start over!
|
||||
confirm_body: This will reset your import. Any changes you have made to the
|
||||
CSV will be erased.
|
||||
|
||||
7
config/locales/views/properties/en.yml
Normal file
7
config/locales/views/properties/en.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
en:
|
||||
properties:
|
||||
create:
|
||||
success: Property created successfully
|
||||
update:
|
||||
success: Property updated successfully
|
||||
7
config/locales/views/vehicles/en.yml
Normal file
7
config/locales/views/vehicles/en.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
en:
|
||||
vehicles:
|
||||
create:
|
||||
success: Vehicle created successfully
|
||||
update:
|
||||
success: Vehicle updated successfully
|
||||
@@ -42,8 +42,8 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :tags, except: %i[ show destroy ] do
|
||||
resources :deletions, only: %i[ new create ], module: :tag
|
||||
resources :tags, except: %i[show destroy] do
|
||||
resources :deletions, only: %i[new create], module: :tag
|
||||
end
|
||||
|
||||
namespace :category do
|
||||
@@ -51,16 +51,16 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :categories do
|
||||
resources :deletions, only: %i[ new create ], module: :category
|
||||
resources :deletions, only: %i[new create], module: :category
|
||||
end
|
||||
|
||||
resources :merchants, only: %i[ index new create edit update destroy ]
|
||||
resources :merchants, only: %i[index new create edit update destroy]
|
||||
|
||||
namespace :account do
|
||||
resources :transfers, only: %i[ new create destroy ]
|
||||
resources :transfers, only: %i[new create destroy]
|
||||
|
||||
namespace :transaction do
|
||||
resources :rules, only: %i[ index ]
|
||||
resources :rules, only: %i[index]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,18 +78,21 @@ Rails.application.routes.draw do
|
||||
scope module: :account do
|
||||
resource :logo, only: :show
|
||||
|
||||
resources :holdings, only: %i[ index new show ]
|
||||
resources :holdings, only: %i[index new show]
|
||||
resources :cashes, only: :index
|
||||
|
||||
resources :transactions, only: %i[ index update ]
|
||||
resources :valuations, only: %i[ index new create ]
|
||||
resources :trades, only: %i[ index new create ]
|
||||
resources :transactions, only: %i[index update]
|
||||
resources :valuations, only: %i[index new create]
|
||||
resources :trades, only: %i[index new create]
|
||||
|
||||
resources :entries, only: %i[ edit update show destroy ]
|
||||
resources :entries, only: %i[edit update show destroy]
|
||||
end
|
||||
end
|
||||
|
||||
resources :transactions, only: %i[ index new create ] do
|
||||
resources :properties, only: %i[create update]
|
||||
resources :vehicles, only: %i[create update]
|
||||
|
||||
resources :transactions, only: %i[index new create] do
|
||||
collection do
|
||||
post "bulk_delete"
|
||||
get "bulk_edit"
|
||||
@@ -100,7 +103,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :institutions, except: %i[ index show ]
|
||||
resources :institutions, except: %i[index show]
|
||||
|
||||
resources :issues, only: :show
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameImportRawCsvStrToRawFileStr < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
rename_column :imports, :raw_csv_str, :raw_file_str
|
||||
end
|
||||
end
|
||||
16
db/migrate/20240822174006_create_addresses.rb
Normal file
16
db/migrate/20240822174006_create_addresses.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class CreateAddresses < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :addresses, id: :uuid do |t|
|
||||
t.references :addressable, type: :uuid, polymorphic: true
|
||||
t.string :line1
|
||||
t.string :line2
|
||||
t.string :county
|
||||
t.string :locality
|
||||
t.string :region
|
||||
t.string :country
|
||||
t.integer :postal_code
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
7
db/migrate/20240822180845_add_property_attributes.rb
Normal file
7
db/migrate/20240822180845_add_property_attributes.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class AddPropertyAttributes < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :properties, :year_built, :integer
|
||||
add_column :properties, :area_value, :integer
|
||||
add_column :properties, :area_unit, :string
|
||||
end
|
||||
end
|
||||
9
db/migrate/20240823125526_add_details_to_vehicle.rb
Normal file
9
db/migrate/20240823125526_add_details_to_vehicle.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class AddDetailsToVehicle < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :vehicles, :year, :integer
|
||||
add_column :vehicles, :mileage_value, :integer
|
||||
add_column :vehicles, :mileage_unit, :string
|
||||
add_column :vehicles, :make, :string
|
||||
add_column :vehicles, :model, :string
|
||||
end
|
||||
end
|
||||
27
db/schema.rb
generated
27
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_08_23_125526) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -152,6 +152,21 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "addressable_type"
|
||||
t.uuid "addressable_id"
|
||||
t.string "line1"
|
||||
t.string "line2"
|
||||
t.string "county"
|
||||
t.string "locality"
|
||||
t.string "region"
|
||||
t.string "country"
|
||||
t.integer "postal_code"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable"
|
||||
end
|
||||
|
||||
create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "color", default: "#6172F3", null: false
|
||||
@@ -289,7 +304,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
|
||||
t.uuid "account_id", null: false
|
||||
t.jsonb "column_mappings"
|
||||
t.enum "status", default: "pending", enum_type: "import_status"
|
||||
t.string "raw_csv_str"
|
||||
t.string "raw_file_str"
|
||||
t.string "normalized_csv_str"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -358,6 +373,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
|
||||
create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "year_built"
|
||||
t.integer "area_value"
|
||||
t.string "area_unit"
|
||||
end
|
||||
|
||||
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -423,6 +441,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
|
||||
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "year"
|
||||
t.integer "mileage_value"
|
||||
t.string "mileage_unit"
|
||||
t.string "make"
|
||||
t.string "model"
|
||||
end
|
||||
|
||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
module Money::Arithmetic
|
||||
CoercedNumeric = Struct.new(:value)
|
||||
CoercedNumeric = Struct.new(:value)
|
||||
|
||||
def +(other)
|
||||
if other.is_a?(Money)
|
||||
self.class.new(amount + other.amount, currency)
|
||||
else
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount + value, currency)
|
||||
end
|
||||
def +(other)
|
||||
if other.is_a?(Money)
|
||||
self.class.new(amount + other.amount, currency)
|
||||
else
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount + value, currency)
|
||||
end
|
||||
end
|
||||
|
||||
def -(other)
|
||||
if other.is_a?(Money)
|
||||
self.class.new(amount - other.amount, currency)
|
||||
else
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount - value, currency)
|
||||
end
|
||||
def -(other)
|
||||
if other.is_a?(Money)
|
||||
self.class.new(amount - other.amount, currency)
|
||||
else
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount - value, currency)
|
||||
end
|
||||
end
|
||||
|
||||
def -@
|
||||
self.class.new(-amount, currency)
|
||||
end
|
||||
def -@
|
||||
self.class.new(-amount, currency)
|
||||
end
|
||||
|
||||
def *(other)
|
||||
raise TypeError, "Can't multiply Money by Money, use Numeric instead" if other.is_a?(self.class)
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount * value, currency)
|
||||
end
|
||||
def *(other)
|
||||
raise TypeError, "Can't multiply Money by Money, use Numeric instead" if other.is_a?(self.class)
|
||||
value = other.is_a?(CoercedNumeric) ? other.value : other
|
||||
self.class.new(amount * value, currency)
|
||||
end
|
||||
|
||||
def /(other)
|
||||
if other.is_a?(self.class)
|
||||
amount / other.amount
|
||||
else
|
||||
raise TypeError, "can't divide Numeric by Money" if other.is_a?(CoercedNumeric)
|
||||
self.class.new(amount / other, currency)
|
||||
end
|
||||
def /(other)
|
||||
if other.is_a?(self.class)
|
||||
amount / other.amount
|
||||
else
|
||||
raise TypeError, "can't divide Numeric by Money" if other.is_a?(CoercedNumeric)
|
||||
self.class.new(amount / other, currency)
|
||||
end
|
||||
end
|
||||
|
||||
def abs
|
||||
self.class.new(amount.abs, currency)
|
||||
end
|
||||
def abs
|
||||
self.class.new(amount.abs, currency)
|
||||
end
|
||||
|
||||
def zero?
|
||||
amount.zero?
|
||||
end
|
||||
def zero?
|
||||
amount.zero?
|
||||
end
|
||||
|
||||
def negative?
|
||||
amount.negative?
|
||||
end
|
||||
def negative?
|
||||
amount.negative?
|
||||
end
|
||||
|
||||
def positive?
|
||||
amount.positive?
|
||||
end
|
||||
def positive?
|
||||
amount.positive?
|
||||
end
|
||||
|
||||
# Override Ruby's coerce method so the order of operands doesn't matter
|
||||
# Wrap in Coerced so we can distinguish between Money and other types
|
||||
def coerce(other)
|
||||
[ self, CoercedNumeric.new(other) ]
|
||||
end
|
||||
# Override Ruby's coerce method so the order of operands doesn't matter
|
||||
# Wrap in Coerced so we can distinguish between Money and other types
|
||||
def coerce(other)
|
||||
[ self, CoercedNumeric.new(other) ]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
class Money::Currency
|
||||
include Comparable
|
||||
include Comparable
|
||||
|
||||
class UnknownCurrencyError < ArgumentError; end
|
||||
class UnknownCurrencyError < ArgumentError; end
|
||||
|
||||
CURRENCIES_FILE_PATH = Rails.root.join("config", "currencies.yml")
|
||||
CURRENCIES_FILE_PATH = Rails.root.join("config", "currencies.yml")
|
||||
|
||||
# Cached instances by iso code
|
||||
@@instances = {}
|
||||
# Cached instances by iso code
|
||||
@@instances = {}
|
||||
|
||||
class << self
|
||||
def new(object)
|
||||
iso_code = case object
|
||||
when String, Symbol
|
||||
object.to_s.downcase
|
||||
when Money::Currency
|
||||
object.iso_code.downcase
|
||||
else
|
||||
raise ArgumentError, "Invalid argument type"
|
||||
end
|
||||
class << self
|
||||
def new(object)
|
||||
iso_code = case object
|
||||
when String, Symbol
|
||||
object.to_s.downcase
|
||||
when Money::Currency
|
||||
object.iso_code.downcase
|
||||
else
|
||||
raise ArgumentError, "Invalid argument type"
|
||||
end
|
||||
|
||||
@@instances[iso_code] ||= super(iso_code)
|
||||
end
|
||||
|
||||
def all
|
||||
@all ||= YAML.load_file(CURRENCIES_FILE_PATH)
|
||||
end
|
||||
|
||||
def all_instances
|
||||
all.values.map { |currency_data| new(currency_data["iso_code"]) }
|
||||
end
|
||||
|
||||
def popular
|
||||
all.values.sort_by { |currency| currency["priority"] }.first(12).map { |currency_data| new(currency_data["iso_code"]) }
|
||||
end
|
||||
@@instances[iso_code] ||= super(iso_code)
|
||||
end
|
||||
|
||||
attr_reader :name, :priority, :iso_code, :iso_numeric, :html_code,
|
||||
:symbol, :minor_unit, :minor_unit_conversion, :smallest_denomination,
|
||||
:separator, :delimiter, :default_format, :default_precision
|
||||
|
||||
def initialize(iso_code)
|
||||
currency_data = self.class.all[iso_code]
|
||||
raise UnknownCurrencyError if currency_data.nil?
|
||||
|
||||
@name = currency_data["name"]
|
||||
@priority = currency_data["priority"]
|
||||
@iso_code = currency_data["iso_code"]
|
||||
@iso_numeric = currency_data["iso_numeric"]
|
||||
@html_code = currency_data["html_code"]
|
||||
@symbol = currency_data["symbol"]
|
||||
@minor_unit = currency_data["minor_unit"]
|
||||
@minor_unit_conversion = currency_data["minor_unit_conversion"]
|
||||
@smallest_denomination = currency_data["smallest_denomination"]
|
||||
@separator = currency_data["separator"]
|
||||
@delimiter = currency_data["delimiter"]
|
||||
@default_format = currency_data["default_format"]
|
||||
@default_precision = currency_data["default_precision"]
|
||||
def all
|
||||
@all ||= YAML.load_file(CURRENCIES_FILE_PATH)
|
||||
end
|
||||
|
||||
def step
|
||||
(1.0/10**default_precision)
|
||||
def all_instances
|
||||
all.values.map { |currency_data| new(currency_data["iso_code"]) }
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
return nil unless other.is_a?(Money::Currency)
|
||||
@iso_code <=> other.iso_code
|
||||
def popular
|
||||
all.values.sort_by { |currency| currency["priority"] }.first(12).map { |currency_data| new(currency_data["iso_code"]) }
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :name, :priority, :iso_code, :iso_numeric, :html_code,
|
||||
:symbol, :minor_unit, :minor_unit_conversion, :smallest_denomination,
|
||||
:separator, :delimiter, :default_format, :default_precision
|
||||
|
||||
def initialize(iso_code)
|
||||
currency_data = self.class.all[iso_code]
|
||||
raise UnknownCurrencyError if currency_data.nil?
|
||||
|
||||
@name = currency_data["name"]
|
||||
@priority = currency_data["priority"]
|
||||
@iso_code = currency_data["iso_code"]
|
||||
@iso_numeric = currency_data["iso_numeric"]
|
||||
@html_code = currency_data["html_code"]
|
||||
@symbol = currency_data["symbol"]
|
||||
@minor_unit = currency_data["minor_unit"]
|
||||
@minor_unit_conversion = currency_data["minor_unit_conversion"]
|
||||
@smallest_denomination = currency_data["smallest_denomination"]
|
||||
@separator = currency_data["separator"]
|
||||
@delimiter = currency_data["delimiter"]
|
||||
@default_format = currency_data["default_format"]
|
||||
@default_precision = currency_data["default_precision"]
|
||||
end
|
||||
|
||||
def step
|
||||
(1.0/10**default_precision)
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
return nil unless other.is_a?(Money::Currency)
|
||||
@iso_code <=> other.iso_code
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
@empty_import = imports(:empty_import)
|
||||
|
||||
@loaded_import = @empty_import.dup
|
||||
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||
@loaded_import.update! raw_file_str: valid_csv_str
|
||||
|
||||
@completed_import = imports(:completed_import)
|
||||
end
|
||||
@@ -59,7 +59,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should save raw CSV if valid" do
|
||||
patch load_import_url(@empty_import), params: { import: { raw_csv_str: valid_csv_str } }
|
||||
patch load_import_url(@empty_import), params: { import: { raw_file_str: valid_csv_str } }
|
||||
|
||||
assert_redirected_to configure_import_path(@empty_import)
|
||||
assert_equal "Import CSV loaded", flash[:notice]
|
||||
@@ -71,17 +71,17 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
valid_csv_str.split("\n").each { |row| csv << row.split(",") }
|
||||
end
|
||||
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
assert_redirected_to configure_import_path(@empty_import)
|
||||
assert_equal "CSV File loaded", flash[:notice]
|
||||
end
|
||||
end
|
||||
|
||||
test "should flash error message if invalid CSV input" do
|
||||
patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } }
|
||||
patch load_import_url(@empty_import), params: { import: { raw_file_str: malformed_csv_str } }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Raw csv str is not a valid CSV format", flash[:alert]
|
||||
assert_equal "Raw file str is not a valid CSV format", flash[:alert]
|
||||
end
|
||||
|
||||
test "should flash error message if invalid CSV file upload" do
|
||||
@@ -89,14 +89,14 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
temp.write(malformed_csv_str)
|
||||
temp.rewind
|
||||
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Raw csv str is not a valid CSV format", flash[:alert]
|
||||
assert_equal "Raw file str is not a valid CSV format", flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
test "should flash error message if no fileprovided for upload" do
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: nil } }
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: nil } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Please select a file to upload", flash[:alert]
|
||||
end
|
||||
@@ -158,7 +158,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should redirect back to clean if data is invalid" do
|
||||
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
|
||||
@empty_import.update! raw_file_str: valid_csv_with_invalid_values
|
||||
|
||||
get confirm_import_url(@empty_import)
|
||||
assert_equal "You have invalid data, please fix before continuing", flash[:alert]
|
||||
|
||||
79
test/controllers/properties_controller_test.rb
Normal file
79
test/controllers/properties_controller_test.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
require "test_helper"
|
||||
|
||||
class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@account = accounts(:property)
|
||||
end
|
||||
|
||||
test "creates property" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Property.count } => 1,
|
||||
-> { Account::Valuation.count } => 2,
|
||||
-> { Account::Entry.count } => 2 do
|
||||
post properties_path, params: {
|
||||
account: {
|
||||
name: "Property",
|
||||
balance: 500000,
|
||||
currency: "USD",
|
||||
accountable_type: "Property",
|
||||
start_date: 3.years.ago.to_date,
|
||||
start_balance: 450000,
|
||||
accountable_attributes: {
|
||||
year_built: 2002,
|
||||
area_value: 1000,
|
||||
area_unit: "sqft",
|
||||
address_attributes: {
|
||||
line1: "123 Main St",
|
||||
line2: "Apt 1",
|
||||
locality: "Los Angeles",
|
||||
region: "CA", # ISO3166-2 code
|
||||
country: "US", # ISO3166-1 Alpha-2 code
|
||||
postal_code: "90001"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_account = Account.order(:created_at).last
|
||||
|
||||
assert created_account.property.year_built.present?
|
||||
assert created_account.property.address.line1.present?
|
||||
|
||||
assert_redirected_to account_path(created_account)
|
||||
assert_equal "Property created successfully", flash[:notice]
|
||||
assert_enqueued_with(job: AccountSyncJob)
|
||||
end
|
||||
|
||||
test "updates property" do
|
||||
assert_no_difference [ "Account.count", "Property.count", "Account::Valuation.count", "Account::Entry.count" ] do
|
||||
patch property_path(@account), params: {
|
||||
account: {
|
||||
name: "Updated Property",
|
||||
balance: 500000,
|
||||
currency: "USD",
|
||||
accountable_type: "Property",
|
||||
accountable_attributes: {
|
||||
id: @account.accountable_id,
|
||||
year_built: 2002,
|
||||
area_value: 1000,
|
||||
area_unit: "sqft",
|
||||
address_attributes: {
|
||||
line1: "123 Main St",
|
||||
line2: "Apt 1",
|
||||
locality: "Los Angeles",
|
||||
region: "CA", # ISO3166-2 code
|
||||
country: "US", # ISO3166-1 Alpha-2 code
|
||||
postal_code: "90001"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(@account)
|
||||
assert_equal "Property updated successfully", flash[:notice]
|
||||
assert_enqueued_with(job: AccountSyncJob)
|
||||
end
|
||||
end
|
||||
71
test/controllers/vehicles_controller_test.rb
Normal file
71
test/controllers/vehicles_controller_test.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
require "test_helper"
|
||||
|
||||
class VehiclesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@account = accounts(:vehicle)
|
||||
end
|
||||
|
||||
test "creates vehicle" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Vehicle.count } => 1,
|
||||
-> { Account::Valuation.count } => 2,
|
||||
-> { Account::Entry.count } => 2 do
|
||||
post vehicles_path, params: {
|
||||
account: {
|
||||
name: "Vehicle",
|
||||
balance: 30000,
|
||||
currency: "USD",
|
||||
accountable_type: "Vehicle",
|
||||
start_date: 1.year.ago.to_date,
|
||||
start_balance: 35000,
|
||||
accountable_attributes: {
|
||||
make: "Toyota",
|
||||
model: "Camry",
|
||||
year: 2020,
|
||||
mileage_value: 15000,
|
||||
mileage_unit: "mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_account = Account.order(:created_at).last
|
||||
|
||||
assert_equal "Toyota", created_account.vehicle.make
|
||||
assert_equal "Camry", created_account.vehicle.model
|
||||
assert_equal 2020, created_account.vehicle.year
|
||||
assert_equal 15000, created_account.vehicle.mileage_value
|
||||
assert_equal "mi", created_account.vehicle.mileage_unit
|
||||
|
||||
assert_redirected_to account_path(created_account)
|
||||
assert_equal "Vehicle created successfully", flash[:notice]
|
||||
assert_enqueued_with(job: AccountSyncJob)
|
||||
end
|
||||
|
||||
test "updates vehicle" do
|
||||
assert_no_difference [ "Account.count", "Vehicle.count", "Account::Valuation.count", "Account::Entry.count" ] do
|
||||
patch vehicle_path(@account), params: {
|
||||
account: {
|
||||
name: "Updated Vehicle",
|
||||
balance: 28000,
|
||||
currency: "USD",
|
||||
accountable_type: "Vehicle",
|
||||
accountable_attributes: {
|
||||
id: @account.accountable_id,
|
||||
make: "Honda",
|
||||
model: "Accord",
|
||||
year: 2021,
|
||||
mileage_value: 20000,
|
||||
mileage_unit: "mi",
|
||||
purchase_price: 32000
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(@account)
|
||||
assert_equal "Vehicle updated successfully", flash[:notice]
|
||||
assert_enqueued_with(job: AccountSyncJob)
|
||||
end
|
||||
end
|
||||
9
test/fixtures/addresses.yml
vendored
Normal file
9
test/fixtures/addresses.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
one:
|
||||
line1: 123 Main Street
|
||||
line2: Apt 4B
|
||||
locality: Los Angeles
|
||||
region: CA
|
||||
country: US
|
||||
postal_code: 90001
|
||||
addressable: one
|
||||
addressable_type: Property
|
||||
2
test/fixtures/imports.yml
vendored
2
test/fixtures/imports.yml
vendored
@@ -9,7 +9,7 @@ completed_import:
|
||||
name: name
|
||||
category: category
|
||||
amount: amount
|
||||
raw_csv_str: |
|
||||
raw_file_str: |
|
||||
date,name,category,tags,amount
|
||||
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
||||
normalized_csv_str: |
|
||||
|
||||
5
test/fixtures/properties.yml
vendored
5
test/fixtures/properties.yml
vendored
@@ -1 +1,4 @@
|
||||
one: { }
|
||||
one:
|
||||
year_built: 2002
|
||||
area_value: 1000
|
||||
area_unit: "sqft"
|
||||
@@ -5,7 +5,7 @@ class ImportJobTest < ActiveJob::TestCase
|
||||
|
||||
test "import is published" do
|
||||
import = imports(:empty_import)
|
||||
import.update! raw_csv_str: valid_csv_str
|
||||
import.update! raw_file_str: valid_csv_str
|
||||
|
||||
assert import.pending?
|
||||
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
require "test_helper"
|
||||
|
||||
class Money::CurrencyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@currency = Money::Currency.new(:usd)
|
||||
end
|
||||
setup do
|
||||
@currency = Money::Currency.new(:usd)
|
||||
end
|
||||
|
||||
test "has many currencies" do
|
||||
assert_operator Money::Currency.all.count, :>, 100
|
||||
end
|
||||
test "has many currencies" do
|
||||
assert_operator Money::Currency.all.count, :>, 100
|
||||
end
|
||||
|
||||
test "can test equality of currencies" do
|
||||
assert_equal Money::Currency.new(:usd), Money::Currency.new(:usd)
|
||||
assert_not_equal Money::Currency.new(:usd), Money::Currency.new(:eur)
|
||||
end
|
||||
test "can test equality of currencies" do
|
||||
assert_equal Money::Currency.new(:usd), Money::Currency.new(:usd)
|
||||
assert_not_equal Money::Currency.new(:usd), Money::Currency.new(:eur)
|
||||
end
|
||||
|
||||
test "can get metadata about a currency" do
|
||||
assert_equal "USD", @currency.iso_code
|
||||
assert_equal "United States Dollar", @currency.name
|
||||
assert_equal "$", @currency.symbol
|
||||
assert_equal 1, @currency.priority
|
||||
assert_equal "Cent", @currency.minor_unit
|
||||
assert_equal 100, @currency.minor_unit_conversion
|
||||
assert_equal 1, @currency.smallest_denomination
|
||||
assert_equal ".", @currency.separator
|
||||
assert_equal ",", @currency.delimiter
|
||||
assert_equal "%u%n", @currency.default_format
|
||||
assert_equal 2, @currency.default_precision
|
||||
end
|
||||
test "can get metadata about a currency" do
|
||||
assert_equal "USD", @currency.iso_code
|
||||
assert_equal "United States Dollar", @currency.name
|
||||
assert_equal "$", @currency.symbol
|
||||
assert_equal 1, @currency.priority
|
||||
assert_equal "Cent", @currency.minor_unit
|
||||
assert_equal 100, @currency.minor_unit_conversion
|
||||
assert_equal 1, @currency.smallest_denomination
|
||||
assert_equal ".", @currency.separator
|
||||
assert_equal ",", @currency.delimiter
|
||||
assert_equal "%u%n", @currency.default_format
|
||||
assert_equal 2, @currency.default_precision
|
||||
end
|
||||
|
||||
test "can extract cents string from amount" do
|
||||
value1 = Money.new(100)
|
||||
value2 = Money.new(100.1)
|
||||
value3 = Money.new(100.12)
|
||||
value4 = Money.new(100.123)
|
||||
value5 = Money.new(200, :jpy)
|
||||
test "can extract cents string from amount" do
|
||||
value1 = Money.new(100)
|
||||
value2 = Money.new(100.1)
|
||||
value3 = Money.new(100.12)
|
||||
value4 = Money.new(100.123)
|
||||
value5 = Money.new(200, :jpy)
|
||||
|
||||
assert_equal "00", value1.cents_str
|
||||
assert_equal "10", value2.cents_str
|
||||
assert_equal "12", value3.cents_str
|
||||
assert_equal "12", value4.cents_str
|
||||
assert_equal "", value5.cents_str
|
||||
assert_equal "00", value1.cents_str
|
||||
assert_equal "10", value2.cents_str
|
||||
assert_equal "12", value3.cents_str
|
||||
assert_equal "12", value4.cents_str
|
||||
assert_equal "", value5.cents_str
|
||||
|
||||
assert_equal "", value4.cents_str(0)
|
||||
assert_equal "1", value4.cents_str(1)
|
||||
assert_equal "12", value4.cents_str(2)
|
||||
assert_equal "123", value4.cents_str(3)
|
||||
end
|
||||
assert_equal "", value4.cents_str(0)
|
||||
assert_equal "1", value4.cents_str(1)
|
||||
assert_equal "12", value4.cents_str(2)
|
||||
assert_equal "123", value4.cents_str(3)
|
||||
end
|
||||
|
||||
test "step returns the smallest value of the currency" do
|
||||
assert_equal 0.01, @currency.step
|
||||
end
|
||||
test "step returns the smallest value of the currency" do
|
||||
assert_equal 0.01, @currency.step
|
||||
end
|
||||
end
|
||||
|
||||
15
test/models/address_test.rb
Normal file
15
test/models/address_test.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
require "test_helper"
|
||||
|
||||
class AddressTest < ActiveSupport::TestCase
|
||||
test "can print a formatted address" do
|
||||
address = Address.new(
|
||||
line1: "123 Main St",
|
||||
locality: "San Francisco",
|
||||
region: "CA",
|
||||
country: "US",
|
||||
postal_code: "94101"
|
||||
)
|
||||
|
||||
assert_equal "123 Main St\n\nSan Francisco, CA 94101\nUS", address.to_s
|
||||
end
|
||||
end
|
||||
@@ -105,25 +105,24 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
test "calculates top movers" do
|
||||
checking_account = create_account(balance: 500, accountable: Depository.new)
|
||||
savings_account = create_account(balance: 1000, accountable: Depository.new)
|
||||
|
||||
create_transaction(account: checking_account, date: 2.days.ago.to_date, amount: -1000)
|
||||
create_transaction(account: checking_account, date: 1.day.ago.to_date, amount: 10)
|
||||
create_transaction(account: savings_account, date: 2.days.ago.to_date, amount: -5000)
|
||||
|
||||
zero_income_zero_expense_account = create_account(balance: 200, accountable: Depository.new)
|
||||
create_transaction(account: zero_income_zero_expense_account, amount: 0)
|
||||
|
||||
snapshot = @family.snapshot_account_transactions
|
||||
top_spenders = snapshot[:top_spenders]
|
||||
top_earners = snapshot[:top_earners]
|
||||
top_savers = snapshot[:top_savers]
|
||||
|
||||
assert_equal 10, top_spenders.first.spending
|
||||
|
||||
assert_equal 5000, top_earners.first.income
|
||||
assert_equal 1000, top_earners.second.income
|
||||
|
||||
assert_equal 1, top_savers.first.savings_rate
|
||||
assert_equal ((1000 - 10).to_f / 1000), top_savers.second.savings_rate
|
||||
assert_equal [ 10 ], top_spenders.map(&:spending)
|
||||
assert_equal [ 5000, 1000 ], top_earners.map(&:income)
|
||||
assert_equal [ 1, 0.99 ], top_savers.map(&:savings_rate)
|
||||
end
|
||||
|
||||
|
||||
test "calculates rolling transaction totals" do
|
||||
account = create_account(balance: 1000, accountable: Depository.new)
|
||||
create_transaction(account: account, date: 2.days.ago.to_date, amount: -500)
|
||||
|
||||
@@ -39,7 +39,7 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
test "CSV with semicolon column separator" do
|
||||
csv = Import::Csv.new(valid_csv_str_with_semicolon_separator, col_sep: ";")
|
||||
|
||||
assert_equal %w[ date name category tags amount ], csv.table.headers
|
||||
assert_equal %w[date name category tags amount], csv.table.headers
|
||||
assert_equal 4, csv.table.size
|
||||
assert_equal "Paycheck", csv.table[3][1]
|
||||
end
|
||||
@@ -72,7 +72,7 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
|
||||
fields = [ date_field, name_field ]
|
||||
|
||||
raw_csv_str = <<-ROWS
|
||||
raw_file_str = <<-ROWS
|
||||
date,Custom Field Header,extra_field
|
||||
invalid_date_value,Starbucks drink,Food
|
||||
2024-01-02,Amazon stuff,Shopping
|
||||
@@ -82,9 +82,9 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
"name" => "Custom Field Header"
|
||||
}
|
||||
|
||||
csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings)
|
||||
csv = Import::Csv.create_with_field_mappings(raw_file_str, fields, mappings)
|
||||
|
||||
assert_equal %w[ date name ], csv.table.headers
|
||||
assert_equal %w[date name], csv.table.headers
|
||||
assert_equal 2, csv.table.size
|
||||
assert_equal "Amazon stuff", csv.table[1][1]
|
||||
end
|
||||
@@ -101,7 +101,7 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
|
||||
fields = [ date_field, name_field ]
|
||||
|
||||
raw_csv_str = <<-ROWS
|
||||
raw_file_str = <<-ROWS
|
||||
date;Custom Field Header;extra_field
|
||||
invalid_date_value;Starbucks drink;Food
|
||||
2024-01-02;Amazon stuff;Shopping
|
||||
@@ -111,9 +111,9 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
"name" => "Custom Field Header"
|
||||
}
|
||||
|
||||
csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings, ";")
|
||||
csv = Import::Csv.create_with_field_mappings(raw_file_str, fields, mappings, ";")
|
||||
|
||||
assert_equal %w[ date name ], csv.table.headers
|
||||
assert_equal %w[date name], csv.table.headers
|
||||
assert_equal 2, csv.table.size
|
||||
assert_equal "Amazon stuff", csv.table[1][1]
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class ImportTest < ActiveSupport::TestCase
|
||||
@empty_import = imports(:empty_import)
|
||||
|
||||
@loaded_import = @empty_import.dup
|
||||
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||
@loaded_import.update! raw_file_str: valid_csv_str
|
||||
end
|
||||
|
||||
test "validates the correct col_sep" do
|
||||
@@ -26,17 +26,17 @@ class ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raw csv input must conform to csv spec" do
|
||||
@empty_import.raw_csv_str = malformed_csv_str
|
||||
@empty_import.raw_file_str = malformed_csv_str
|
||||
assert_not @empty_import.valid?
|
||||
|
||||
@empty_import.raw_csv_str = valid_csv_str
|
||||
@empty_import.raw_file_str = valid_csv_str
|
||||
assert @empty_import.valid?
|
||||
end
|
||||
|
||||
test "can update csv value without affecting raw input" do
|
||||
assert_equal "Starbucks drink", @loaded_import.csv.table[0][1]
|
||||
|
||||
prior_raw_csv_str_value = @loaded_import.raw_csv_str
|
||||
prior_raw_file_str_value = @loaded_import.raw_file_str
|
||||
prior_normalized_csv_str_value = @loaded_import.normalized_csv_str
|
||||
|
||||
@loaded_import.update_csv! \
|
||||
@@ -45,7 +45,7 @@ class ImportTest < ActiveSupport::TestCase
|
||||
value: "new_category"
|
||||
|
||||
assert_equal "new_category", @loaded_import.csv.table[0][1]
|
||||
assert_equal prior_raw_csv_str_value, @loaded_import.raw_csv_str
|
||||
assert_equal prior_raw_file_str_value, @loaded_import.raw_file_str
|
||||
assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str
|
||||
end
|
||||
|
||||
@@ -74,7 +74,7 @@ class ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "publishes a valid import with missing data" do
|
||||
@empty_import.update! raw_csv_str: valid_csv_with_missing_data
|
||||
@empty_import.update! raw_file_str: valid_csv_with_missing_data
|
||||
assert_difference -> { Category.count } => 1,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Entry.count } => 2 do
|
||||
@@ -89,7 +89,7 @@ class ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "failed publish results in error status" do
|
||||
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
|
||||
@empty_import.update! raw_file_str: valid_csv_with_invalid_values
|
||||
|
||||
assert_difference "Account::Transaction.count", 0 do
|
||||
@empty_import.publish
|
||||
@@ -102,7 +102,7 @@ class ImportTest < ActiveSupport::TestCase
|
||||
test "can create transactions from csv with custom column separator" do
|
||||
loaded_import = @empty_import.dup
|
||||
|
||||
loaded_import.update! raw_csv_str: valid_csv_str_with_semicolon_separator, col_sep: ";"
|
||||
loaded_import.update! raw_file_str: valid_csv_str_with_semicolon_separator, col_sep: ";"
|
||||
transactions = loaded_import.dry_run
|
||||
|
||||
assert_equal 4, transactions.count
|
||||
|
||||
@@ -1,141 +1,141 @@
|
||||
require "test_helper"
|
||||
require "ostruct"
|
||||
class ValueGroupTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
# Level 1
|
||||
@assets = ValueGroup.new("Assets", :usd)
|
||||
setup do
|
||||
# Level 1
|
||||
@assets = ValueGroup.new("Assets", :usd)
|
||||
|
||||
# Level 2
|
||||
@depositories = @assets.add_child_group("Depositories", :usd)
|
||||
@other_assets = @assets.add_child_group("Other Assets", :usd)
|
||||
# Level 2
|
||||
@depositories = @assets.add_child_group("Depositories", :usd)
|
||||
@other_assets = @assets.add_child_group("Other Assets", :usd)
|
||||
|
||||
# Level 3 (leaf/value nodes)
|
||||
@checking_node = @depositories.add_value_node(OpenStruct.new({ name: "Checking", value: Money.new(5000) }), Money.new(5000))
|
||||
@savings_node = @depositories.add_value_node(OpenStruct.new({ name: "Savings", value: Money.new(20000) }), Money.new(20000))
|
||||
@collectable_node = @other_assets.add_value_node(OpenStruct.new({ name: "Collectable", value: Money.new(550) }), Money.new(550))
|
||||
# Level 3 (leaf/value nodes)
|
||||
@checking_node = @depositories.add_value_node(OpenStruct.new({ name: "Checking", value: Money.new(5000) }), Money.new(5000))
|
||||
@savings_node = @depositories.add_value_node(OpenStruct.new({ name: "Savings", value: Money.new(20000) }), Money.new(20000))
|
||||
@collectable_node = @other_assets.add_value_node(OpenStruct.new({ name: "Collectable", value: Money.new(550) }), Money.new(550))
|
||||
end
|
||||
|
||||
test "empty group works" do
|
||||
group = ValueGroup.new("Root", :usd)
|
||||
|
||||
assert_equal "Root", group.name
|
||||
assert_equal [], group.children
|
||||
assert_equal 0, group.sum
|
||||
assert_equal 0, group.avg
|
||||
assert_equal 100, group.percent_of_total
|
||||
assert_nil group.parent
|
||||
end
|
||||
|
||||
test "group without value nodes has no value" do
|
||||
assets = ValueGroup.new("Assets")
|
||||
depositories = assets.add_child_group("Depositories")
|
||||
|
||||
assert_equal 0, assets.sum
|
||||
assert_equal 0, depositories.sum
|
||||
end
|
||||
|
||||
test "sum equals value at leaf level" do
|
||||
assert_equal @checking_node.value, @checking_node.sum
|
||||
assert_equal @savings_node.value, @savings_node.sum
|
||||
assert_equal @collectable_node.value, @collectable_node.sum
|
||||
end
|
||||
|
||||
test "value is nil at rollup levels" do
|
||||
assert_not_equal @depositories.value, @depositories.sum
|
||||
assert_nil @depositories.value
|
||||
assert_nil @other_assets.value
|
||||
end
|
||||
|
||||
test "generates list of value nodes regardless of level in hierarchy" do
|
||||
assert_equal [ @checking_node, @savings_node, @collectable_node ], @assets.value_nodes
|
||||
assert_equal [ @checking_node, @savings_node ], @depositories.value_nodes
|
||||
assert_equal [ @collectable_node ], @other_assets.value_nodes
|
||||
end
|
||||
|
||||
test "group with value nodes aggregates totals correctly" do
|
||||
assert_equal Money.new(5000), @checking_node.sum
|
||||
assert_equal Money.new(20000), @savings_node.sum
|
||||
assert_equal Money.new(550), @collectable_node.sum
|
||||
|
||||
assert_equal Money.new(25000), @depositories.sum
|
||||
assert_equal Money.new(550), @other_assets.sum
|
||||
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
test "group averages leaf nodes" do
|
||||
assert_equal Money.new(5000), @checking_node.avg
|
||||
assert_equal Money.new(20000), @savings_node.avg
|
||||
assert_equal Money.new(550), @collectable_node.avg
|
||||
|
||||
assert_in_delta 12500, @depositories.avg.amount, 0.01
|
||||
assert_in_delta 550, @other_assets.avg.amount, 0.01
|
||||
assert_in_delta 8516.67, @assets.avg.amount, 0.01
|
||||
end
|
||||
|
||||
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
|
||||
test "group calculates percent of parent total" do
|
||||
assert_equal 100, @assets.percent_of_total
|
||||
assert_in_delta 97.85, @depositories.percent_of_total, 0.1
|
||||
assert_in_delta 2.15, @other_assets.percent_of_total, 0.1
|
||||
assert_in_delta 80.0, @savings_node.percent_of_total, 0.1
|
||||
assert_in_delta 20.0, @checking_node.percent_of_total, 0.1
|
||||
assert_equal 100, @collectable_node.percent_of_total
|
||||
end
|
||||
|
||||
test "handles unbalanced tree" do
|
||||
vehicles = @assets.add_child_group("Vehicles")
|
||||
|
||||
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
|
||||
test "can attach and aggregate time series" do
|
||||
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(4000) }, { date: Date.current, value: Money.new(5000) } ])
|
||||
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(19000) }, { date: Date.current, value: Money.new(20000) } ])
|
||||
|
||||
@checking_node.series = checking_series
|
||||
@savings_node.series = savings_series
|
||||
|
||||
assert_not_nil @checking_node.series
|
||||
assert_not_nil @savings_node.series
|
||||
|
||||
assert_equal @checking_node.sum, @checking_node.series.last.value
|
||||
assert_equal @savings_node.sum, @savings_node.series.last.value
|
||||
|
||||
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
|
||||
assert_equal aggregated_depository_series.values, @depositories.series.values
|
||||
assert_equal aggregated_assets_series.values, @assets.series.values
|
||||
end
|
||||
|
||||
test "attached series must be a TimeSeries" do
|
||||
assert_raises(RuntimeError) do
|
||||
@checking_node.series = []
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot add time series to non-leaf node" do
|
||||
assert_raises(RuntimeError) do
|
||||
@assets.series = TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
test "can only add value node at leaf level of tree" do
|
||||
root = ValueGroup.new("Root Level")
|
||||
grandparent = root.add_child_group("Grandparent")
|
||||
parent = grandparent.add_child_group("Parent")
|
||||
|
||||
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
|
||||
test "empty group works" do
|
||||
group = ValueGroup.new("Root", :usd)
|
||||
|
||||
assert_equal "Root", group.name
|
||||
assert_equal [], group.children
|
||||
assert_equal 0, group.sum
|
||||
assert_equal 0, group.avg
|
||||
assert_equal 100, group.percent_of_total
|
||||
assert_nil group.parent
|
||||
end
|
||||
|
||||
test "group without value nodes has no value" do
|
||||
assets = ValueGroup.new("Assets")
|
||||
depositories = assets.add_child_group("Depositories")
|
||||
|
||||
assert_equal 0, assets.sum
|
||||
assert_equal 0, depositories.sum
|
||||
end
|
||||
|
||||
test "sum equals value at leaf level" do
|
||||
assert_equal @checking_node.value, @checking_node.sum
|
||||
assert_equal @savings_node.value, @savings_node.sum
|
||||
assert_equal @collectable_node.value, @collectable_node.sum
|
||||
end
|
||||
|
||||
test "value is nil at rollup levels" do
|
||||
assert_not_equal @depositories.value, @depositories.sum
|
||||
assert_nil @depositories.value
|
||||
assert_nil @other_assets.value
|
||||
end
|
||||
|
||||
test "generates list of value nodes regardless of level in hierarchy" do
|
||||
assert_equal [ @checking_node, @savings_node, @collectable_node ], @assets.value_nodes
|
||||
assert_equal [ @checking_node, @savings_node ], @depositories.value_nodes
|
||||
assert_equal [ @collectable_node ], @other_assets.value_nodes
|
||||
end
|
||||
|
||||
test "group with value nodes aggregates totals correctly" do
|
||||
assert_equal Money.new(5000), @checking_node.sum
|
||||
assert_equal Money.new(20000), @savings_node.sum
|
||||
assert_equal Money.new(550), @collectable_node.sum
|
||||
|
||||
assert_equal Money.new(25000), @depositories.sum
|
||||
assert_equal Money.new(550), @other_assets.sum
|
||||
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
test "group averages leaf nodes" do
|
||||
assert_equal Money.new(5000), @checking_node.avg
|
||||
assert_equal Money.new(20000), @savings_node.avg
|
||||
assert_equal Money.new(550), @collectable_node.avg
|
||||
|
||||
assert_in_delta 12500, @depositories.avg.amount, 0.01
|
||||
assert_in_delta 550, @other_assets.avg.amount, 0.01
|
||||
assert_in_delta 8516.67, @assets.avg.amount, 0.01
|
||||
end
|
||||
|
||||
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
|
||||
test "group calculates percent of parent total" do
|
||||
assert_equal 100, @assets.percent_of_total
|
||||
assert_in_delta 97.85, @depositories.percent_of_total, 0.1
|
||||
assert_in_delta 2.15, @other_assets.percent_of_total, 0.1
|
||||
assert_in_delta 80.0, @savings_node.percent_of_total, 0.1
|
||||
assert_in_delta 20.0, @checking_node.percent_of_total, 0.1
|
||||
assert_equal 100, @collectable_node.percent_of_total
|
||||
end
|
||||
|
||||
test "handles unbalanced tree" do
|
||||
vehicles = @assets.add_child_group("Vehicles")
|
||||
|
||||
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
|
||||
test "can attach and aggregate time series" do
|
||||
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(4000) }, { date: Date.current, value: Money.new(5000) } ])
|
||||
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(19000) }, { date: Date.current, value: Money.new(20000) } ])
|
||||
|
||||
@checking_node.series = checking_series
|
||||
@savings_node.series = savings_series
|
||||
|
||||
assert_not_nil @checking_node.series
|
||||
assert_not_nil @savings_node.series
|
||||
|
||||
assert_equal @checking_node.sum, @checking_node.series.last.value
|
||||
assert_equal @savings_node.sum, @savings_node.series.last.value
|
||||
|
||||
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
|
||||
assert_equal aggregated_depository_series.values, @depositories.series.values
|
||||
assert_equal aggregated_assets_series.values, @assets.series.values
|
||||
end
|
||||
|
||||
test "attached series must be a TimeSeries" do
|
||||
assert_raises(RuntimeError) do
|
||||
@checking_node.series = []
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot add time series to non-leaf node" do
|
||||
assert_raises(RuntimeError) do
|
||||
@assets.series = TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
test "can only add value node at leaf level of tree" do
|
||||
root = ValueGroup.new("Root Level")
|
||||
grandparent = root.add_child_group("Grandparent")
|
||||
parent = grandparent.add_child_group("Parent")
|
||||
|
||||
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
assert_raises(RuntimeError) do
|
||||
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,11 +21,25 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
end
|
||||
|
||||
test "can create property account" do
|
||||
assert_account_created("Property")
|
||||
assert_account_created "Property" do
|
||||
fill_in "Year built (optional)", with: 2005
|
||||
fill_in "Area value (optional)", with: 2250
|
||||
fill_in "Address line 1", with: "123 Main St"
|
||||
fill_in "Address line 2", with: "Apt 4B"
|
||||
fill_in "City", with: "San Francisco"
|
||||
fill_in "State", with: "CA"
|
||||
fill_in "Postal code (optional)", with: "94101"
|
||||
fill_in "Country", with: "US"
|
||||
end
|
||||
end
|
||||
|
||||
test "can create vehicle account" do
|
||||
assert_account_created("Vehicle")
|
||||
assert_account_created "Vehicle" do
|
||||
fill_in "Make", with: "Toyota"
|
||||
fill_in "Model", with: "Camry"
|
||||
fill_in "Year", with: "2020"
|
||||
fill_in "Mileage", with: "30000"
|
||||
end
|
||||
end
|
||||
|
||||
test "can create other asset account" do
|
||||
@@ -50,7 +64,7 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
click_link "sidebar-new-account"
|
||||
end
|
||||
|
||||
def assert_account_created(accountable_type)
|
||||
def assert_account_created(accountable_type, &block)
|
||||
click_link humanized_accountable(accountable_type)
|
||||
click_link "Enter account balance manually"
|
||||
|
||||
@@ -59,9 +73,11 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
fill_in "Account name", with: account_name
|
||||
select "Chase", from: "Financial institution"
|
||||
fill_in "account[balance]", with: 100.99
|
||||
check "Add a start balance for this account"
|
||||
fill_in "Start date (optional)", with: 10.days.ago.to_date
|
||||
fill_in "Start balance (optional)", with: 95
|
||||
|
||||
yield if block_given?
|
||||
|
||||
click_button "Add #{humanized_accountable(accountable_type).downcase}"
|
||||
|
||||
find("details", text: humanized_accountable(accountable_type)).click
|
||||
@@ -69,6 +85,17 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
|
||||
visit accounts_url
|
||||
assert_text account_name
|
||||
|
||||
visit account_url(Account.order(:created_at).last)
|
||||
|
||||
within "header" do
|
||||
find('button[data-menu-target="button"]').click
|
||||
click_on "Edit"
|
||||
end
|
||||
|
||||
fill_in "Account name", with: "Updated account name"
|
||||
click_button "Update #{humanized_accountable(accountable_type).downcase}"
|
||||
assert_selector "h2", text: "Updated account name"
|
||||
end
|
||||
|
||||
def humanized_accountable(accountable_type)
|
||||
|
||||
@@ -57,7 +57,7 @@ class ImportsTest < ApplicationSystemTestCase
|
||||
assert_selector "h1", text: "Load import"
|
||||
|
||||
within "form" do
|
||||
fill_in "import_raw_csv_str", with: <<-ROWS
|
||||
fill_in "import_raw_file_str", with: <<-ROWS
|
||||
date,Custom Name Column,category,amount
|
||||
invalid_date,Starbucks drink,Food,-20.50
|
||||
2024-01-01,Amazon purchase,Shopping,-89.50
|
||||
@@ -115,7 +115,7 @@ class ImportsTest < ApplicationSystemTestCase
|
||||
|
||||
click_button "Upload CSV"
|
||||
|
||||
find(".csv-drop-box").drop File.join(file_fixture_path, "transactions.csv")
|
||||
find(".raw-file-drop-box").drop File.join(file_fixture_path, "transactions.csv")
|
||||
assert_selector "div.csv-preview", text: "transactions.csv"
|
||||
|
||||
click_button "Next"
|
||||
|
||||
Reference in New Issue
Block a user