diff --git a/.gitignore b/.gitignore index 7d98173a..7e4402d4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ # Ignore Jetbrains IDEs .idea +# Ignore VS Code +.vscode + # Ignore macOS specific files */.DS_Store .DS_Store diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 886962e3..1071f0f6 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -13,6 +13,7 @@ class RegistrationsController < ApplicationController @user.family = family if @user.save + Transaction::Category.create_default_categories(@user.family) login @user flash[:notice] = t(".success") redirect_to root_path diff --git a/app/models/family.rb b/app/models/family.rb index e514a783..e69620fc 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,6 +2,7 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy has_many :transactions, through: :accounts + has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category" def net_worth accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END") diff --git a/app/models/transaction.rb b/app/models/transaction.rb index ed884dde..4ffa367d 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,6 @@ class Transaction < ApplicationRecord belongs_to :account + belongs_to :category, optional: true after_commit :sync_account diff --git a/app/models/transaction/category.rb b/app/models/transaction/category.rb new file mode 100644 index 00000000..270accd2 --- /dev/null +++ b/app/models/transaction/category.rb @@ -0,0 +1,38 @@ +class Transaction::Category < ApplicationRecord + has_many :transactions + belongs_to :family + + before_update :clear_internal_category, if: :name_changed? + + DEFAULT_CATEGORIES = [ + { internal_category: "income", color: "#fd7f6f" }, + { internal_category: "food_and_drink", color: "#7eb0d5" }, + { internal_category: "entertainment", color: "#b2e061" }, + { internal_category: "personal_care", color: "#bd7ebe" }, + { internal_category: "general_services", color: "#ffb55a" }, + { internal_category: "auto_and_transport", color: "#ffee65" }, + { internal_category: "rent_and_utilities", color: "#beb9db" }, + { internal_category: "home_improvement", color: "#fdcce5" } + ] + + def self.create_default_categories(family) + if family.transaction_categories.size > 0 + raise ArgumentError, "Family already has some categories" + end + + family_id = family.id + categories = self::DEFAULT_CATEGORIES.map { |c| { + name: I18n.t("transaction.default_category.#{c[:internal_category]}"), + internal_category: c[:internal_category], + color: c[:color], + family_id: + } } + self.insert_all(categories) + end + + private + + def clear_internal_category + self.internal_category = nil + end +end diff --git a/config/locales/models/transaction/en.yml b/config/locales/models/transaction/en.yml new file mode 100644 index 00000000..8e71e17b --- /dev/null +++ b/config/locales/models/transaction/en.yml @@ -0,0 +1,12 @@ +--- +en: + transaction: + default_category: + auto_and_transport: Auto & Transport + entertainment: Entertainment + food_and_drink: Food & Drink + general_services: General Services + home_improvement: Home Improvement + income: Income + personal_care: Personal Care + rent_and_utilities: Rent & Utilities diff --git a/db/migrate/20240307082827_create_transaction_categories.rb b/db/migrate/20240307082827_create_transaction_categories.rb new file mode 100644 index 00000000..870dbd70 --- /dev/null +++ b/db/migrate/20240307082827_create_transaction_categories.rb @@ -0,0 +1,14 @@ +class CreateTransactionCategories < ActiveRecord::Migration[7.2] + def change + create_table :transaction_categories, id: :uuid do |t| + t.string "name", null: false + t.string "color", default: "#6172F3", null: false + t.string "internal_category" + t.references :family, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + + add_reference :transactions, :category, foreign_key: { to_table: :transaction_categories }, type: :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index fbd2e521..a192a46a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_03_06_193345) do +ActiveRecord::Schema[7.2].define(version: 2024_03_07_082827) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -79,7 +79,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_06_193345) do t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0" t.string "converted_currency", default: "USD" t.string "status", default: "OK" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.boolean "is_active", default: true, null: false t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" @@ -198,6 +198,16 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_06_193345) do t.index ["token"], name: "index_invite_codes_on_token", unique: true end + create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "color", default: "#6172F3", null: false + t.string "internal_category" + t.uuid "family_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_transaction_categories_on_family_id" + end + create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name" t.date "date", null: false @@ -206,7 +216,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_06_193345) do t.uuid "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "category_id" t.index ["account_id"], name: "index_transactions_on_account_id" + t.index ["category_id"], name: "index_transactions_on_category_id" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -233,7 +245,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_06_193345) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "accounts", "families" + add_foreign_key "transaction_categories", "families" add_foreign_key "transactions", "accounts", on_delete: :cascade + add_foreign_key "transactions", "transaction_categories", column: "category_id" add_foreign_key "users", "families" add_foreign_key "valuations", "accounts", on_delete: :cascade end diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index c4bd0c05..fc467025 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -11,6 +11,7 @@ namespace :demo_data do puts "Reset user: #{user.email} with family: #{family.name}" + Transaction::Category.create_default_categories(family) checking = Account.find_or_create_by(name: "Demo Checking") do |a| a.family = family diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index cbf3b6c4..bd4b262a 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -6,7 +6,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "create" do + test "create redirects to correct URL" do post registration_url, params: { user: { email: "john@example.com", password: "password", @@ -15,6 +15,15 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_url end + test "create seeds default transaction categories" do + assert_difference "Transaction::Category.count", Transaction::Category::DEFAULT_CATEGORIES.size do + post registration_url, params: { user: { + email: "john@example.com", + password: "password", + password_confirmation: "password" } } + end + end + test "create when hosted requires an invite code" do in_hosted_app do assert_no_difference "User.count" do diff --git a/test/fixtures/transaction/categories.yml b/test/fixtures/transaction/categories.yml new file mode 100644 index 00000000..ad3144d8 --- /dev/null +++ b/test/fixtures/transaction/categories.yml @@ -0,0 +1,10 @@ +income: + name: Income + internal_category: income + color: "#fd7f6f" + family: dylan_family + +food_and_drink: + name: Food & Drink + internal_category: food_and_drink + family: dylan_family diff --git a/test/fixtures/transactions.yml b/test/fixtures/transactions.yml index 9cccabb6..c7859ac0 100644 --- a/test/fixtures/transactions.yml +++ b/test/fixtures/transactions.yml @@ -4,12 +4,14 @@ checking_one: date: <%= 5.days.ago.to_date %> amount: 10 account: checking + category: food_and_drink checking_two: name: Chipotle date: <%= 12.days.ago.to_date %> amount: 30 account: checking + category: food_and_drink checking_three: name: Amazon @@ -22,6 +24,7 @@ checking_four: date: <%= 22.days.ago.to_date %> amount: -1075 account: checking + category: income checking_five: name: Netflix @@ -35,12 +38,14 @@ savings_one: date: <%= 5.days.ago.to_date %> amount: -200 account: savings_with_valuation_overrides + category: income savings_two: name: Check Deposit date: <%= 12.days.ago.to_date %> amount: -50 account: savings_with_valuation_overrides + category: income savings_three: name: Withdrawal @@ -53,6 +58,7 @@ savings_four: date: <%= 29.days.ago.to_date %> amount: -500 account: savings_with_valuation_overrides + category: income # Credit card account transactions credit_card_one: @@ -60,12 +66,14 @@ credit_card_one: date: <%= 5.days.ago.to_date %> amount: 10 account: credit_card + category: food_and_drink credit_card_two: name: Chipotle date: <%= 12.days.ago.to_date %> amount: 30 account: credit_card + category: food_and_drink credit_card_three: name: Amazon diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 7db5ea75..b0e132d4 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -30,6 +30,12 @@ class FamilyTest < ActiveSupport::TestCase end end + test "should destroy dependent transaction categories" do + assert_difference("Transaction::Category.count", -@family.transaction_categories.count) do + @family.destroy + end + end + test "should calculate total assets" do assert_equal BigDecimal("25550"), @family.assets end diff --git a/test/models/transaction/category_test.rb b/test/models/transaction/category_test.rb new file mode 100644 index 00000000..473ef913 --- /dev/null +++ b/test/models/transaction/category_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class Transaction::CategoryTest < ActiveSupport::TestCase + def setup + @family = families(:dylan_family) + end + + test "create_default_categories should generate categories if none exist" do + @family.accounts.destroy_all + @family.transaction_categories.destroy_all + assert_difference "Transaction::Category.count", Transaction::Category::DEFAULT_CATEGORIES.size do + Transaction::Category.create_default_categories(@family) + end + end + + test "create_default_categories should raise when there are existing categories" do + assert_raises(ArgumentError) do + Transaction::Category.create_default_categories(@family) + end + end + + test "updating name should clear the internal_category field" do + category = Transaction::Category.take + assert_changes "category.reload.internal_category", to: nil do + category.update_attribute(:name, "new name") + end + end + + test "updating other field than name should not clear the internal_category field" do + category = Transaction::Category.take + assert_no_changes "category.reload.internal_category" do + category.update_attribute(:color, "#000") + end + end +end