1
0
Fork 0
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:
Jakub Kottnauer 2024-03-07 19:15:50 +01:00 committed by GitHub
parent ad7136cb63
commit 90d0cc0c39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 156 additions and 3 deletions

3
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View 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

View 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

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

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

View file

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

View file

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

View 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

View file

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

View file

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

View 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