Compare commits

...

15 Commits

Author SHA1 Message Date
Zach Gollwitzer
52c729dc33 Bump to v0.1.0-alpha.16
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-23 10:39:14 -04:00
Zach Gollwitzer
de9723d63a Fix file upload UI opening twice (#1119)
* Fix file selector opening twice

* rename click() method

* Remove unused method

* Credit original author

Co-authored-by: Tony Yesudas <tonyvince7@gmail.com>

---------

Co-authored-by: Tony Yesudas <tonyvince7@gmail.com>
2024-08-23 10:30:08 -04:00
Zach Gollwitzer
eef4c2643b Rubocop updates (#1118)
* Minimal code style enforcement

* Formatting and lint code updates (no change in functionality)
2024-08-23 10:06:24 -04:00
Zach Gollwitzer
359bceb58e Vehicle view (#1117) 2024-08-23 09:33:42 -04:00
Zach Gollwitzer
e856691c86 Add Property Details View (#1116)
* Add backend for property account details

* Rubocop updates

* Add property form with details

* Revert "Rubocop updates"

This reverts commit 05b0b8f3a4.

* Bump brakeman to latest version

* Add overview section to property view

* Lint fixes
2024-08-23 08:47:08 -04:00
Zach Gollwitzer
4433488562 Fix holding name error (#1113)
* Add optional debugger to bin/dev script

* Fix holding naming
2024-08-20 17:35:23 -04:00
Zach Gollwitzer
37ae51f68a Fix query when account has zero income and expense (#1112)
* Reproduce error

* Apply fix

* Remove uneeded helper
2024-08-20 15:44:32 -04:00
dependabot[bot]
793a6027a3 Bump tailwindcss-rails from 2.7.2 to 2.7.3 (#1103)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.2 to 2.7.3.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.2...v2.7.3)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:42:18 -04:00
dependabot[bot]
4d20b5f2d4 Bump good_job from 4.1.1 to 4.2.0 (#1102)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.1.1 to 4.2.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.1.1...v4.2.0)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:42:02 -04:00
dependabot[bot]
7966c44d7f Bump propshaft from 0.9.0 to 0.9.1 (#1104)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.9.0...v0.9.1)

---
updated-dependencies:
- dependency-name: propshaft
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:41:51 -04:00
dependabot[bot]
30b2ff7aa6 Bump ruby-lsp-rails from 0.3.12 to 0.3.13 (#1107)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.12 to 0.3.13.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.12...v0.3.13)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:26:01 -04:00
dependabot[bot]
f85fdba366 Bump aws-sdk-s3 from 1.157.0 to 1.158.0 (#1105)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.157.0 to 1.158.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:25:43 -04:00
dependabot[bot]
0cb4e968a0 Bump stimulus-rails from 1.3.3 to 1.3.4 (#1106)
Bumps [stimulus-rails](https://github.com/hotwired/stimulus-rails) from 1.3.3 to 1.3.4.
- [Release notes](https://github.com/hotwired/stimulus-rails/releases)
- [Commits](https://github.com/hotwired/stimulus-rails/compare/v1.3.3...v1.3.4)

---
updated-dependencies:
- dependency-name: stimulus-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:25:31 -04:00
dependabot[bot]
8ebf18e04d Bump sentry-ruby from 5.18.2 to 5.19.0 (#1108)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.18.2 to 5.19.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.18.2...5.19.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:25:16 -04:00
Pedro Carmona
0c1ff00c1e Refactor: Allow other import files (#1099)
* Rename stimulus controller

* feature: rename raw_csv_str to raw_file_str
2024-08-19 09:25:07 -04:00
96 changed files with 1432 additions and 690 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ class Account::Transaction < ApplicationRecord
private
def searchable_keys
%i[ categories merchants ]
%i[categories merchants]
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class TimeSeries
DIRECTIONS = %w[ up down ].freeze
DIRECTIONS = %w[up down].freeze
attr_reader :values, :favorable_direction

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
<%# locals: (account:) %>
<%= render partial: "accounts/accountables/#{account.accountable_type.downcase}/overview", locals: { account: account } %>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.*'

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.15"
"0.1.0-alpha.16"
end
end
end

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

View File

@@ -5,5 +5,5 @@ en:
models:
import:
attributes:
raw_csv_str:
raw_file_str:
invalid_csv_format: is not a valid CSV format

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
---
en:
properties:
create:
success: Property created successfully
update:
success: Property updated successfully

View File

@@ -0,0 +1,7 @@
---
en:
vehicles:
create:
success: Vehicle created successfully
update:
success: Vehicle updated successfully

View File

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

View File

@@ -0,0 +1,5 @@
class RenameImportRawCsvStrToRawFileStr < ActiveRecord::Migration[7.2]
def change
rename_column :imports, :raw_csv_str, :raw_file_str
end
end

View 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

View 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

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

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

@@ -1 +1,4 @@
one: { }
one:
year_built: 2002
area_value: 1000
area_unit: "sqft"

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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