diff --git a/app/controllers/transactions/categories_controller.rb b/app/controllers/transactions/categories_controller.rb index 036ea4bf..1ce3a8f1 100644 --- a/app/controllers/transactions/categories_controller.rb +++ b/app/controllers/transactions/categories_controller.rb @@ -32,6 +32,6 @@ class Transactions::CategoriesController < ApplicationController end def category_params - params.require(:transaction_category).permit(:name, :name, :color) + params.require(:transaction_category).permit(:name, :color) end end diff --git a/app/controllers/transactions/merchants_controller.rb b/app/controllers/transactions/merchants_controller.rb index dae8ba3a..600d07b3 100644 --- a/app/controllers/transactions/merchants_controller.rb +++ b/app/controllers/transactions/merchants_controller.rb @@ -1,4 +1,45 @@ class Transactions::MerchantsController < ApplicationController + before_action :set_merchant, only: %i[ edit update destroy ] + def index + @merchants = Current.family.transaction_merchants + end + + def new + @merchant = Transaction::Merchant.new + end + + def create + if Current.family.transaction_merchants.create(merchant_params) + redirect_to transactions_merchants_path, notice: t(".success") + else + render transactions_merchants_path, status: :unprocessable_entity, notice: t(".error") + end + end + + def edit + end + + def update + if @merchant.update(merchant_params) + redirect_to transactions_merchants_path, notice: t(".success") + else + render transactions_merchants_path, status: :unprocessable_entity, notice: t(".error") + end + end + + def destroy + @merchant.destroy! + redirect_to transactions_merchants_path, notice: t(".success") + end + + private + + def set_merchant + @merchant = Current.family.transaction_merchants.find(params[:id]) + end + + def merchant_params + params.require(:transaction_merchant).permit(:name, :color) end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 46bdcfb9..9bb985b3 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -148,6 +148,6 @@ class TransactionsController < ApplicationController # Only allow a list of trusted parameters through. def transaction_params - params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id) + params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 19e60b2d..9180c92b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -22,9 +22,9 @@ module ApplicationHelper # Wrap view with <%= modal do %> ... <% end %> to have it open in a modal # Make sure to add data-turbo-frame="modal" to the link/button that opens the modal - def modal(&block) + def modal(options = {}, &block) content = capture &block - render partial: "shared/modal", locals: { content: content } + render partial: "shared/modal", locals: { content:, classes: options[:classes] } end def account_groups(period: nil) diff --git a/app/javascript/controllers/merchant_avatar_controller.js b/app/javascript/controllers/merchant_avatar_controller.js new file mode 100644 index 00000000..5f5134b2 --- /dev/null +++ b/app/javascript/controllers/merchant_avatar_controller.js @@ -0,0 +1,32 @@ +import {Controller} from "@hotwired/stimulus"; + +// Connects to data-controller="merchant-avatar" +// Used by the transaction merchant form to show a preview of what the avatar will look like +export default class extends Controller { + static targets = [ + "name", + "color", + "avatar" + ]; + + connect() { + this.nameTarget.addEventListener("input", this.handleNameChange); + this.colorTarget.addEventListener("input", this.handleColorChange); + } + + disconnect() { + this.nameTarget.removeEventListener("input", this.handleNameChange); + this.colorTarget.removeEventListener("input", this.handleColorChange); + } + + handleNameChange = (e) => { + this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase(); + } + + handleColorChange = (e) => { + const color = e.currentTarget.value; + this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`; + this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; + this.avatarTarget.style.color = color; + } +} diff --git a/app/models/family.rb b/app/models/family.rb index 16b81599..3f7747d9 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -3,6 +3,7 @@ class Family < ApplicationRecord has_many :accounts, dependent: :destroy has_many :transactions, through: :accounts has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category" + has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant" def snapshot(period = Period.all) query = accounts.active.joins(:balances) diff --git a/app/models/transaction.rb b/app/models/transaction.rb index baa8005d..68b6b06f 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -3,6 +3,7 @@ class Transaction < ApplicationRecord belongs_to :account belongs_to :category, optional: true + belongs_to :merchant, optional: true validates :name, :date, :amount, :account, presence: true @@ -56,7 +57,7 @@ class Transaction < ApplicationRecord end def self.ransackable_associations(auth_object = nil) - %w[category account] + %w[category merchant account] end def self.build_filter_list(params, family) @@ -77,7 +78,11 @@ class Transaction < ApplicationRecord value.each do |category_id| filters << { type: "category", value: family.transaction_categories.find(category_id), original: { key: key, value: category_id } } end - when "category_name_or_account_name_or_name_cont" + when "merchant_id_in" + value.each do |merchant_id| + filters << { type: "merchant", value: family.transaction_merchants.find(merchant_id), original: { key: key, value: merchant_id } } + end + when "category_name_or_merchant_name_or_account_name_or_name_cont" filters << { type: "search", value: value, original: { key: key, value: nil } } when "date_gteq" date_filters[:gteq] = value diff --git a/app/models/transaction/merchant.rb b/app/models/transaction/merchant.rb new file mode 100644 index 00000000..e0395bb8 --- /dev/null +++ b/app/models/transaction/merchant.rb @@ -0,0 +1,18 @@ +class Transaction::Merchant < ApplicationRecord + has_many :transactions, dependent: :nullify + belongs_to :family + + validates :name, :color, :family, presence: true + + scope :alphabetically, -> { order(:name) } + + COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] + + def self.ransackable_attributes(auth_object = nil) + %w[name id] + end + + def self.ransackable_associations(auth_object = nil) + %w[] + end +end diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb index e3571783..a1a7b7d7 100644 --- a/app/views/shared/_modal.html.erb +++ b/app/views/shared/_modal.html.erb @@ -1,6 +1,6 @@ -<%# locals: (content:) -%> +<%# locals: (content:, classes:) -%> <%= turbo_frame_tag "modal" do %> - +
<%= content %>
diff --git a/app/views/transactions/_filter.html.erb b/app/views/transactions/_filter.html.erb index 19405d8b..f4024611 100644 --- a/app/views/transactions/_filter.html.erb +++ b/app/views/transactions/_filter.html.erb @@ -11,6 +11,11 @@

<%= filter[:value].name %>

+ <% when "merchant" %> +
+
+

<%= filter[:value].name %>

+
<% when "search" %>
<%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %> diff --git a/app/views/transactions/merchants/_avatar.html.erb b/app/views/transactions/merchants/_avatar.html.erb new file mode 100644 index 00000000..a18d8006 --- /dev/null +++ b/app/views/transactions/merchants/_avatar.html.erb @@ -0,0 +1,7 @@ +<%# locals: (merchant:) %> +<% name = merchant.name || "?" %> +<% background_color = "color-mix(in srgb, #{merchant.color} 5%, white)" %> +<% border_color = "color-mix(in srgb, #{merchant.color} 10%, white)" %> + + <%= name[0].upcase %> + diff --git a/app/views/transactions/merchants/_form.html.erb b/app/views/transactions/merchants/_form.html.erb new file mode 100644 index 00000000..76c21cb5 --- /dev/null +++ b/app/views/transactions/merchants/_form.html.erb @@ -0,0 +1,27 @@ +<% is_editing = @merchant.id.present? %> +
+ <%= form_with model: @merchant, url: is_editing ? transactions_merchant_path(@merchant) : transactions_merchants_path, method: is_editing ? :patch : :post, scope: :transaction_merchant, data: { turbo: false } do |f| %> +
+
+ <%= render partial: "transactions/merchants/avatar", locals: { merchant: } %> +
+
+ <%= f.hidden_field :color, data: { select_target: "input", merchant_avatar_target: "color" } %> +
    + <% Transaction::Merchant::COLORS.each do |color| %> +
  • +
    +
  • + <% end %> +
+
+
+ <%= f.text_field :name, placeholder: t(".name_placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { merchant_avatar_target: "name" } %> +
+
+ +
+ <%= f.submit(is_editing ? t(".submit_edit") : t(".submit_create")) %> +
+ <% end %> +
diff --git a/app/views/transactions/merchants/_list.html.erb b/app/views/transactions/merchants/_list.html.erb new file mode 100644 index 00000000..c3bf656e --- /dev/null +++ b/app/views/transactions/merchants/_list.html.erb @@ -0,0 +1,41 @@ +<%# locals: (merchants:) %> +<% merchants.each.with_index do |merchant, index| %> +
+
+ <%= render partial: "transactions/merchants/avatar", locals: { merchant: } %> +

+ <%= merchant.name %> +

+
+
+ + +
+
+ <% unless index == merchants.size - 1 %> +
+ <% end %> +<% end %> diff --git a/app/views/transactions/merchants/edit.html.erb b/app/views/transactions/merchants/edit.html.erb new file mode 100644 index 00000000..713a3c88 --- /dev/null +++ b/app/views/transactions/merchants/edit.html.erb @@ -0,0 +1,10 @@ +<%= modal classes: "max-w-fit" do %> +
+
+

<%= t(".title") %>

+ <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> +
+ + <%= render "form", merchant: @merchant %> +
+<% end %> diff --git a/app/views/transactions/merchants/index.html.erb b/app/views/transactions/merchants/index.html.erb index cbee888c..3905b807 100644 --- a/app/views/transactions/merchants/index.html.erb +++ b/app/views/transactions/merchants/index.html.erb @@ -2,11 +2,34 @@ <%= render "settings/nav" %> <% end %>
-

Merchants

+
+

<%= t(".title") %>

+ <%= link_to new_transactions_merchant_path, class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new_short") %> + <% end %> +
-
-

Manage transaction merchants coming soon...

-
+ <% if @merchants.empty? %> +
+
+

<%= t(".empty") %>

+ <%= link_to new_transactions_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new_long") %> + <% end %> +
+
+ <% else %> +
+
+

<%= t(".title") %>

+ · +

<%= @merchants.count %>

+
+ <%= render partial: "transactions/merchants/list", locals: { merchants: @merchants } %> +
+ <% end %>
<%= previous_setting("Categories", transactions_categories_path) %> diff --git a/app/views/transactions/merchants/new.html.erb b/app/views/transactions/merchants/new.html.erb new file mode 100644 index 00000000..713a3c88 --- /dev/null +++ b/app/views/transactions/merchants/new.html.erb @@ -0,0 +1,10 @@ +<%= modal classes: "max-w-fit" do %> +
+
+

<%= t(".title") %>

+ <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> +
+ + <%= render "form", merchant: @merchant %> +
+<% end %> diff --git a/app/views/transactions/search_form/_merchant_filter.html.erb b/app/views/transactions/search_form/_merchant_filter.html.erb index dd8e24a0..37d55d70 100644 --- a/app/views/transactions/search_form/_merchant_filter.html.erb +++ b/app/views/transactions/search_form/_merchant_filter.html.erb @@ -1,4 +1,15 @@ <%# locals: (form:) %> -
-

Filter by merchant coming soon...

+
+
+ + <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %> +
+
+ <% Current.family.transaction_merchants.each do |merchant| %> +
+ <%= form.check_box :merchant_id_in, { multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, merchant.id, nil %> + <%= form.label :merchant_id_in, merchant.name, value: merchant.id, class: "text-sm text-gray-900" %> +
+ <% end %> +
diff --git a/app/views/transactions/search_form/_search_filter.html.erb b/app/views/transactions/search_form/_search_filter.html.erb index 0a2bed41..4273c689 100644 --- a/app/views/transactions/search_form/_search_filter.html.erb +++ b/app/views/transactions/search_form/_search_filter.html.erb @@ -1,7 +1,7 @@ <%# locals: (form:) %>
- <%= form.search_field :category_name_or_account_name_or_name_cont, - placeholder: "Search transaction by name, category or amount", + <%= form.search_field :category_name_or_merchant_name_or_account_name_or_name_cont, + placeholder: "Search transaction by name, merchant, category or amount", class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg", "data-auto-submit-form-target": "auto" %> <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %> diff --git a/config/locales/views/transaction/en.yml b/config/locales/views/transaction/en.yml index 740d27cb..016fd9b8 100644 --- a/config/locales/views/transaction/en.yml +++ b/config/locales/views/transaction/en.yml @@ -27,5 +27,34 @@ en: income: Income submit: Add transaction transfer: Transfer + merchants: + create: + error: Error creating merchant + success: New merchant created successfully + destroy: + success: Merchant deleted successfully + edit: + title: Edit merchant + form: + name_placeholder: Merchant name + submit_create: Add merchant + submit_edit: Update + index: + empty: No merchants yet + new_long: New merchant + new_short: New + title: Merchants + list: + confirm_accept: Delete merchant + confirm_body: Are you sure you want to delete this merchant? Removing this + merchant will unlink all associated transactions and may effect your reporting. + confirm_title: Delete merchant? + delete: Delete merchant + edit: Edit merchant + new: + title: New merchant + update: + error: Error updating merchant + success: Merchant updated successfully update: success: Transaction updated successfully diff --git a/config/routes.rb b/config/routes.rb index 04b040dd..971bdfb5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,7 +25,7 @@ Rails.application.routes.draw do # TODO: These are *placeholders* # Uncomment `only` and add the necessary actions as they are implemented. resources :rules, only: [ :index ] - resources :merchants, only: [ :index ] + resources :merchants, only: %i[index new create edit update destroy] end resources :transactions do diff --git a/db/migrate/20240426191312_add_transaction_merchants.rb b/db/migrate/20240426191312_add_transaction_merchants.rb new file mode 100644 index 00000000..929c5cbc --- /dev/null +++ b/db/migrate/20240426191312_add_transaction_merchants.rb @@ -0,0 +1,13 @@ +class AddTransactionMerchants < ActiveRecord::Migration[7.2] + def change + create_table :transaction_merchants, id: :uuid do |t| + t.string "name", null: false + t.string "color", default: "#e99537", null: false + t.references :family, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + + add_reference :transactions, :merchant, foreign_key: { to_table: :transaction_merchants }, type: :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index 924ce938..c1a5a895 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_04_25_000110) do +ActiveRecord::Schema[7.2].define(version: 2024_04_26_191312) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -219,6 +219,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_25_000110) do t.index ["family_id"], name: "index_transaction_categories_on_family_id" end + create_table "transaction_merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "color", default: "#e99537", null: false + 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_merchants_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 @@ -230,8 +239,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_25_000110) do t.uuid "category_id" t.boolean "excluded", default: false t.text "notes" + t.uuid "merchant_id" t.index ["account_id"], name: "index_transactions_on_account_id" t.index ["category_id"], name: "index_transactions_on_category_id" + t.index ["merchant_id"], name: "index_transactions_on_merchant_id" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -264,8 +275,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_25_000110) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "accounts", "families" add_foreign_key "transaction_categories", "families" + add_foreign_key "transaction_merchants", "families" add_foreign_key "transactions", "accounts", on_delete: :cascade add_foreign_key "transactions", "transaction_categories", column: "category_id", on_delete: :nullify + add_foreign_key "transactions", "transaction_merchants", column: "merchant_id" add_foreign_key "users", "families" add_foreign_key "valuations", "accounts", on_delete: :cascade end diff --git a/test/controllers/transactions/merchants_controller_test.rb b/test/controllers/transactions/merchants_controller_test.rb new file mode 100644 index 00000000..67d11e04 --- /dev/null +++ b/test/controllers/transactions/merchants_controller_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Transactions::MerchantsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @merchant = transaction_merchants(:netflix) + end + + test "index" do + get transactions_merchants_path + assert_response :success + end + + test "new" do + get new_transactions_merchant_path + assert_response :success + end + + test "should create merchant" do + assert_difference("Transaction::Merchant.count") do + post transactions_merchants_url, params: { transaction_merchant: { name: "new merchant", color: "#000000" } } + end + + assert_redirected_to transactions_merchants_path + end + + test "should update merchant" do + patch transactions_merchant_url(@merchant), params: { transaction_merchant: { name: "new name", color: "#000000" } } + assert_redirected_to transactions_merchants_path + end + + test "should destroy merchant" do + assert_difference("Transaction::Merchant.count", -1) do + delete transactions_merchant_url(@merchant) + end + + assert_redirected_to transactions_merchants_path + end +end diff --git a/test/controllers/valuations_controller_test.rb b/test/controllers/valuations_controller_test.rb index 2c422408..43fc7b6b 100644 --- a/test/controllers/valuations_controller_test.rb +++ b/test/controllers/valuations_controller_test.rb @@ -16,6 +16,8 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest assert_difference("Valuation.count") do post account_valuations_url(@account), params: { valuation: { value: 1, date: Date.current, type: "Appraisal" } } end + + assert_redirected_to account_path(@valuation.account) end test "create should sync account with correct start date" do diff --git a/test/fixtures/transaction/merchants.yml b/test/fixtures/transaction/merchants.yml new file mode 100644 index 00000000..ade78c09 --- /dev/null +++ b/test/fixtures/transaction/merchants.yml @@ -0,0 +1,9 @@ +netflix: + name: Netflix + color: "#fd7f6f" + family: dylan_family + +amazon: + name: Amazon + color: "#fd7f6f" + family: dylan_family diff --git a/test/fixtures/transactions.yml b/test/fixtures/transactions.yml index a8e6929f..410a4265 100644 --- a/test/fixtures/transactions.yml +++ b/test/fixtures/transactions.yml @@ -21,6 +21,7 @@ checking_three: amount: 20 account: checking currency: USD + merchant: amazon checking_four: name: Paycheck @@ -36,6 +37,7 @@ checking_five: amount: 15 account: checking currency: USD + merchant: netflix # Savings account that has these transactions and valuation overrides savings_one: @@ -92,6 +94,7 @@ credit_card_three: amount: 20 account: credit_card currency: USD + merchant: amazon credit_card_four: name: CC Payment diff --git a/test/models/family_test.rb b/test/models/family_test.rb index ed01ac8b..ba57279b 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -51,6 +51,12 @@ class FamilyTest < ActiveSupport::TestCase end end + test "should destroy dependent merchants" do + assert_difference("Transaction::Merchant.count", -@family.transaction_merchants.count) do + @family.destroy + end + end + test "should calculate total assets" do expected = @expected_snapshots.last["assets"].to_d assert_equal Money.new(expected), @family.assets