mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Add backend support for transaction categories (#524)
* Add backend support for transaction categories * Fix tests * Localize default category names * Add tests * Remove category icon and set default color
This commit is contained in:
parent
ad7136cb63
commit
90d0cc0c39
14 changed files with 156 additions and 3 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -41,6 +41,9 @@
|
||||||
# Ignore Jetbrains IDEs
|
# Ignore Jetbrains IDEs
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Ignore VS Code
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Ignore macOS specific files
|
# Ignore macOS specific files
|
||||||
*/.DS_Store
|
*/.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
@ -13,6 +13,7 @@ class RegistrationsController < ApplicationController
|
||||||
@user.family = family
|
@user.family = family
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
Transaction::Category.create_default_categories(@user.family)
|
||||||
login @user
|
login @user
|
||||||
flash[:notice] = t(".success")
|
flash[:notice] = t(".success")
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
|
|
|
@ -2,6 +2,7 @@ class Family < ApplicationRecord
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
|
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||||
|
|
||||||
def net_worth
|
def net_worth
|
||||||
accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END")
|
accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END")
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
class Transaction < ApplicationRecord
|
class Transaction < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
belongs_to :category, optional: true
|
||||||
|
|
||||||
after_commit :sync_account
|
after_commit :sync_account
|
||||||
|
|
||||||
|
|
38
app/models/transaction/category.rb
Normal file
38
app/models/transaction/category.rb
Normal file
|
@ -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
|
12
config/locales/models/transaction/en.yml
Normal file
12
config/locales/models/transaction/en.yml
Normal file
|
@ -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
|
14
db/migrate/20240307082827_create_transaction_categories.rb
Normal file
14
db/migrate/20240307082827_create_transaction_categories.rb
Normal file
|
@ -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
|
18
db/schema.rb
generated
18
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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.decimal "converted_balance", precision: 19, scale: 4, default: "0.0"
|
||||||
t.string "converted_currency", default: "USD"
|
t.string "converted_currency", default: "USD"
|
||||||
t.string "status", default: "OK"
|
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.boolean "is_active", default: true, null: false
|
||||||
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
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
|
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||||
end
|
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|
|
create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.date "date", null: false
|
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.uuid "account_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 ["account_id"], name: "index_transactions_on_account_id"
|
||||||
|
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
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 "account_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
|
add_foreign_key "transaction_categories", "families"
|
||||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
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 "users", "families"
|
||||||
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ namespace :demo_data do
|
||||||
|
|
||||||
puts "Reset user: #{user.email} with family: #{family.name}"
|
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|
|
checking = Account.find_or_create_by(name: "Demo Checking") do |a|
|
||||||
a.family = family
|
a.family = family
|
||||||
|
|
|
@ -6,7 +6,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create" do
|
test "create redirects to correct URL" do
|
||||||
post registration_url, params: { user: {
|
post registration_url, params: { user: {
|
||||||
email: "john@example.com",
|
email: "john@example.com",
|
||||||
password: "password",
|
password: "password",
|
||||||
|
@ -15,6 +15,15 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_redirected_to root_url
|
assert_redirected_to root_url
|
||||||
end
|
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
|
test "create when hosted requires an invite code" do
|
||||||
in_hosted_app do
|
in_hosted_app do
|
||||||
assert_no_difference "User.count" do
|
assert_no_difference "User.count" do
|
||||||
|
|
10
test/fixtures/transaction/categories.yml
vendored
Normal file
10
test/fixtures/transaction/categories.yml
vendored
Normal file
|
@ -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
|
8
test/fixtures/transactions.yml
vendored
8
test/fixtures/transactions.yml
vendored
|
@ -4,12 +4,14 @@ checking_one:
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
amount: 10
|
amount: 10
|
||||||
account: checking
|
account: checking
|
||||||
|
category: food_and_drink
|
||||||
|
|
||||||
checking_two:
|
checking_two:
|
||||||
name: Chipotle
|
name: Chipotle
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
amount: 30
|
amount: 30
|
||||||
account: checking
|
account: checking
|
||||||
|
category: food_and_drink
|
||||||
|
|
||||||
checking_three:
|
checking_three:
|
||||||
name: Amazon
|
name: Amazon
|
||||||
|
@ -22,6 +24,7 @@ checking_four:
|
||||||
date: <%= 22.days.ago.to_date %>
|
date: <%= 22.days.ago.to_date %>
|
||||||
amount: -1075
|
amount: -1075
|
||||||
account: checking
|
account: checking
|
||||||
|
category: income
|
||||||
|
|
||||||
checking_five:
|
checking_five:
|
||||||
name: Netflix
|
name: Netflix
|
||||||
|
@ -35,12 +38,14 @@ savings_one:
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
amount: -200
|
amount: -200
|
||||||
account: savings_with_valuation_overrides
|
account: savings_with_valuation_overrides
|
||||||
|
category: income
|
||||||
|
|
||||||
savings_two:
|
savings_two:
|
||||||
name: Check Deposit
|
name: Check Deposit
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
amount: -50
|
amount: -50
|
||||||
account: savings_with_valuation_overrides
|
account: savings_with_valuation_overrides
|
||||||
|
category: income
|
||||||
|
|
||||||
savings_three:
|
savings_three:
|
||||||
name: Withdrawal
|
name: Withdrawal
|
||||||
|
@ -53,6 +58,7 @@ savings_four:
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 29.days.ago.to_date %>
|
||||||
amount: -500
|
amount: -500
|
||||||
account: savings_with_valuation_overrides
|
account: savings_with_valuation_overrides
|
||||||
|
category: income
|
||||||
|
|
||||||
# Credit card account transactions
|
# Credit card account transactions
|
||||||
credit_card_one:
|
credit_card_one:
|
||||||
|
@ -60,12 +66,14 @@ credit_card_one:
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
amount: 10
|
amount: 10
|
||||||
account: credit_card
|
account: credit_card
|
||||||
|
category: food_and_drink
|
||||||
|
|
||||||
credit_card_two:
|
credit_card_two:
|
||||||
name: Chipotle
|
name: Chipotle
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
amount: 30
|
amount: 30
|
||||||
account: credit_card
|
account: credit_card
|
||||||
|
category: food_and_drink
|
||||||
|
|
||||||
credit_card_three:
|
credit_card_three:
|
||||||
name: Amazon
|
name: Amazon
|
||||||
|
|
|
@ -30,6 +30,12 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
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
|
test "should calculate total assets" do
|
||||||
assert_equal BigDecimal("25550"), @family.assets
|
assert_equal BigDecimal("25550"), @family.assets
|
||||||
end
|
end
|
||||||
|
|
35
test/models/transaction/category_test.rb
Normal file
35
test/models/transaction/category_test.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue