diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a78dd2a7..83ea0258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,18 +85,16 @@ jobs: ruby-version: .ruby-version bundler-cache: true - - name: Run tests + - name: Run tests and smoke test seed env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:setup test test:system - - - name: Smoke test database seeds - env: - RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432 - run: bin/rails db:drop db:create db:migrate db:seed + run: | + bin/rails db:create + bin/rails db:schema:load + bin/rails test:all + bin/rails db:seed - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb new file mode 100644 index 00000000..3a562445 --- /dev/null +++ b/app/controllers/transactions_controller.rb @@ -0,0 +1,64 @@ +class TransactionsController < ApplicationController + before_action :authenticate_user! + before_action :set_transaction, only: %i[ show edit update destroy ] + + def index + @transactions = Current.family.transactions + end + + def show + end + + def new + @transaction = Transaction.new + end + + def edit + end + + def create + @transaction = Transaction.new(transaction_params) + account = Current.family.accounts.find(params[:transaction][:account_id]) + + raise ActiveRecord::RecordNotFound, "Account not found or not accessible" if account.nil? + + @transaction.account = account + + respond_to do |format| + if @transaction.save + format.html { redirect_to transaction_url(@transaction), notice: "Transaction was successfully created." } + else + format.html { render :new, status: :unprocessable_entity } + end + end + end + + def update + respond_to do |format| + if @transaction.update(transaction_params) + format.html { redirect_to transaction_url(@transaction), notice: "Transaction was successfully updated." } + else + format.html { render :edit, status: :unprocessable_entity } + end + end + end + + def destroy + @transaction.destroy! + + respond_to do |format| + format.html { redirect_to transactions_url, notice: "Transaction was successfully destroyed." } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_transaction + @transaction = Transaction.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def transaction_params + params.require(:transaction).permit(:name, :date, :amount, :currency) + end +end diff --git a/app/helpers/application_form_builder.rb b/app/helpers/application_form_builder.rb index 0f560255..e5c1f24a 100644 --- a/app/helpers/application_form_builder.rb +++ b/app/helpers/application_form_builder.rb @@ -34,6 +34,18 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder end end + def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) + default_options = { class: "form-field__input" } + merged_options = default_options.merge(html_options) + + return super(method, collection, value_method, text_method, options, merged_options) unless options[:label] + + @template.form_field_tag do + label(method, *label_args(options)) + + super(method, collection, value_method, text_method, options, merged_options.except(:label)) + end + end + def submit(value = nil, options = {}) value, options = nil, value if value.is_a?(Hash) default_options = { class: "form-field__submit" } diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb new file mode 100644 index 00000000..36098d12 --- /dev/null +++ b/app/helpers/transactions_helper.rb @@ -0,0 +1,2 @@ +module TransactionsHelper +end diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js new file mode 100644 index 00000000..75902361 --- /dev/null +++ b/app/javascript/controllers/tabs_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="tabs" +export default class extends Controller { + static classes = ["active"]; + static targets = ["btn", "tab"]; + static values = { defaultTab: String }; + + connect() { + const defaultTab = this.defaultTabValue; + this.tabTargets.forEach((tab) => { + if (tab.id === defaultTab) { + tab.hidden = false; + this.btnTargets + .find((btn) => btn.dataset.id === defaultTab) + .classList.add(...this.activeClasses); + } else { + tab.hidden = true; + } + }); + } + + select(event) { + const selectedTabId = event.currentTarget.dataset.id; + this.tabTargets.forEach((tab) => (tab.hidden = tab.id !== selectedTabId)); + this.btnTargets.forEach((btn) => + btn.classList.toggle( + ...this.activeClasses, + btn.dataset.id === selectedTabId + ) + ); + } +} diff --git a/app/models/account.rb b/app/models/account.rb index bfc8dac1..559c1270 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -3,6 +3,7 @@ class Account < ApplicationRecord belongs_to :family has_many :balances, class_name: "AccountBalance" has_many :valuations + has_many :transactions delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy diff --git a/app/models/family.rb b/app/models/family.rb index 38c321c0..fca65cb0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,5 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy + has_many :transactions, through: :accounts end diff --git a/app/models/transaction.rb b/app/models/transaction.rb new file mode 100644 index 00000000..0da6c5db --- /dev/null +++ b/app/models/transaction.rb @@ -0,0 +1,3 @@ +class Transaction < ApplicationRecord + belongs_to :account +end diff --git a/app/views/accounts/_account_history.html.erb b/app/views/accounts/_account_history.html.erb new file mode 100644 index 00000000..25f839c9 --- /dev/null +++ b/app/views/accounts/_account_history.html.erb @@ -0,0 +1,29 @@ +<%# locals: (account:, valuation_series:) %> +
+
+

History

+ <%= link_to new_account_valuation_path(account), data: { turbo_frame: dom_id(Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> + <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> + New entry + <% end %> +
+
+
+
+
date
+
+
+
value
+
+
change
+
+
+
+ <%= turbo_frame_tag dom_id(Valuation.new) %> + <%= turbo_frame_tag "valuations_list" do %> + <%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuation_series } %> + <% end %> +
+
+
+
diff --git a/app/views/accounts/_transactions.html.erb b/app/views/accounts/_transactions.html.erb new file mode 100644 index 00000000..cc050643 --- /dev/null +++ b/app/views/accounts/_transactions.html.erb @@ -0,0 +1,19 @@ +<%# locals: (transactions:)%> +
+
+

Transactions

+ <%= link_to new_transaction_path, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> + <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> + New transaction + <% end %> +
+ <% if transactions.empty? %> +

No transactions for this account yet.

+ <% else %> +
+ <% transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %> + <%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %> + <% end %> +
+ <% end %> +
diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 069e77a2..cee93a47 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -59,31 +59,17 @@ <% end %> -
-
-

History

- <%= link_to new_account_valuation_path(@account), data: { turbo_frame: dom_id(Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> - <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> - New entry - <% end %> +
+
+ +
-
-
-
-
date
-
-
-
value
-
-
change
-
-
-
- <%= turbo_frame_tag dom_id(Valuation.new) %> - <%= turbo_frame_tag "valuations_list" do %> - <%= render partial: "accounts/account_valuation_list", locals: { valuation_series: @valuation_series } %> - <% end %> -
+
+
+ <%= render partial: "accounts/account_history", locals: { account: @account, valuation_series: @valuation_series } %> +
+
+ <%= render partial: "accounts/transactions", locals: { transactions: @account.transactions.order(date: :desc) } %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index c7714e09..47236ace 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,10 +6,8 @@ - <%= csrf_meta_tags %> <%= csp_meta_tag %> - @@ -18,15 +16,12 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> <%= hotwire_livereload_tags if Rails.env.development? %> - <%= turbo_refreshes_with method: :morph, scroll: :preserve %> <%= yield :head %> -
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %> -
@@ -37,14 +32,12 @@
<%= Current.user.email.first %>
- -
@@ -69,13 +62,10 @@ <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %> <% end %>
- - <%= link_to new_account_path, class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %>

<%= t('.new_account') %>

<% end %> - <% Accountable.types.each do |type| %> <%= render 'accounts/account_list', type: Accountable.from_type(type) %> <% end %> diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb new file mode 100644 index 00000000..9d71cb14 --- /dev/null +++ b/app/views/transactions/_form.html.erb @@ -0,0 +1,7 @@ +<%= form_with model: @transaction do |f| %> + <%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account" } %> + <%= f.date_field :date, label: "Date" %> + <%= f.text_field :name, label: "Name" %> + <%= f.number_field :amount, label: "Amount", step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00 %> + <%= f.submit %> +<% end %> diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb new file mode 100644 index 00000000..375cfb80 --- /dev/null +++ b/app/views/transactions/_transaction.html.erb @@ -0,0 +1,12 @@ +
+
+
<%= transaction.name[0].upcase %>
+

<%= transaction.name %>

+
+
+

<%= transaction.account.name %>

+
+
+

"><%= number_to_currency(-transaction.amount, { precision: 2 }) %>

+
+
diff --git a/app/views/transactions/_transaction_group.html.erb b/app/views/transactions/_transaction_group.html.erb new file mode 100644 index 00000000..ceddad2c --- /dev/null +++ b/app/views/transactions/_transaction_group.html.erb @@ -0,0 +1,10 @@ +<%# locals: (date:, transactions:) %> +
+
+

<%= date.strftime('%b %d, %Y') %> · <%= transactions.size %>

+ <%= number_to_currency(-transactions.sum(&:amount)) %> +
+
+ <%= render partial: "transactions/transaction", collection: transactions %> +
+
diff --git a/app/views/transactions/edit.html.erb b/app/views/transactions/edit.html.erb new file mode 100644 index 00000000..80feff59 --- /dev/null +++ b/app/views/transactions/edit.html.erb @@ -0,0 +1,8 @@ +
+

Editing transaction

+ + <%= render "form", transaction: @transaction %> + + <%= link_to "Show this transaction", @transaction, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> + <%= link_to "Back to transactions", transactions_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb new file mode 100644 index 00000000..f67b0511 --- /dev/null +++ b/app/views/transactions/index.html.erb @@ -0,0 +1,75 @@ +
+
+

Transactions

+
+
+ USD $ + <%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %> +
+
+
+ <%= lucide_icon("settings-2", class: "cursor-not-allowed w-5 h-5 text-gray-500") %> + <%= link_to new_transaction_path, class: "rounded-full w-9 h-9 bg-gray-900 text-white flex items-center justify-center hover:bg-gray-700" do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <% end %> +
+
+
+
+
+

Total transactions

+

<%= @transactions.size %>

+
+
+

Income

+

+ <%= number_to_currency(@transactions.select { |t| t.amount < 0 }.sum(&:amount).abs, precision: 2) %> +

+
+
+

Expenses

+

+ <%= number_to_currency(@transactions.select { |t| t.amount >= 0 }.sum(&:amount), precision: 2) %> +

+
+
+
+
+
+ <%= form_with url: transactions_path, method: :get, local: true, html: { role: 'search' } do |form| %> +
+ <%= form.text_field :search, placeholder: "Search transaction by merchant, category or amount", class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg cursor-not-allowed", 'data-action': "input->search#perform", disabled: true %> + <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %> +
+ <% end %> +
+
+ +
+ <%= form_with url: "#", method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %> + <%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block h-full w-full border border-gray-200 rounded-lg text-sm py-2 pr-8 pl-2 cursor-not-allowed", onchange: "this.form.submit();", disabled: true } %> + <% end %> +
+
+
+

transaction

+
+
+

account

+

amount

+
+
+ <% if @transactions.empty? %> +

No transactions for this account yet.

+ <% else %> +
+ <% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %> + <%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb new file mode 100644 index 00000000..03da6bb2 --- /dev/null +++ b/app/views/transactions/new.html.erb @@ -0,0 +1,7 @@ +
+

New transaction

+ <%= render "form", transaction: @transaction %> +
+
+ <%= link_to "Back to transactions", transactions_path, class: "mt-8 underline text-lg font-bold" %> +
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb new file mode 100644 index 00000000..5d193dcc --- /dev/null +++ b/app/views/transactions/show.html.erb @@ -0,0 +1,10 @@ +
+
+ <%= render @transaction %> + <%= link_to "Edit this transaction", edit_transaction_path(@transaction), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+ <%= button_to "Destroy this transaction", transaction_path(@transaction), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %> +
+ <%= link_to "Back to transactions", transactions_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 184c6834..a6d16f74 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,7 @@ Rails.application.routes.draw do resource :password resource :settings, only: %i[edit update] + resources :transactions resources :accounts, shallow: true do resources :valuations end diff --git a/db/migrate/20240223162105_create_transactions.rb b/db/migrate/20240223162105_create_transactions.rb new file mode 100644 index 00000000..b89dd24b --- /dev/null +++ b/db/migrate/20240223162105_create_transactions.rb @@ -0,0 +1,13 @@ +class CreateTransactions < ActiveRecord::Migration[7.2] + def change + create_table :transactions, id: :uuid do |t| + t.string :name + t.date :date, null: false + t.decimal :amount, precision: 19, scale: 4, null: false + t.string :currency, default: "USD", null: false + t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade } + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f9c3f52d..1e310aa8 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_02_22_144849) do +ActiveRecord::Schema[7.2].define(version: 2024_02_23_162105) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -196,6 +196,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_22_144849) do t.index ["token"], name: "index_invite_codes_on_token", unique: true end + create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.date "date", null: false + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_transactions_on_account_id" + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "first_name" @@ -220,6 +231,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_22_144849) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "accounts", "families" + add_foreign_key "transactions", "accounts", on_delete: :cascade add_foreign_key "users", "families" add_foreign_key "valuations", "accounts", on_delete: :cascade end diff --git a/db/seeds.rb b/db/seeds.rb index de405863..2f0f3fac 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -40,6 +40,15 @@ valuations = [ { date: Date.today - 3, value: 301900 } ] +transactions = [ + { date: Date.today - 27, amount: 7.56, currency: "USD", name: "Starbucks" }, + { date: Date.today - 18, amount: -2000, currency: "USD", name: "Paycheck" }, + { date: Date.today - 18, amount: 18.20, currency: "USD", name: "Walgreens" }, + { date: Date.today - 13, amount: 34.20, currency: "USD", name: "Chipotle" }, + { date: Date.today - 9, amount: -200, currency: "USD", name: "Birthday check" }, + { date: Date.today - 5, amount: 85.00, currency: "USD", name: "Amazon stuff" } +] + # Represent system-generated "Balances" at various dates, based on valuations balances = [ { date: Date.today - 30, balance: 300000 }, @@ -75,6 +84,8 @@ balances = [ { date: Date.today, balance: current_balance } ] + + valuations.each do |valuation| Valuation.find_or_create_by( account_id: account.id, @@ -93,3 +104,14 @@ balances.each do |balance| balance_record.balance = balance[:balance] end end + +transactions.each do |transaction| + Transaction.find_or_create_by( + account_id: account.id, + date: transaction[:date], + amount: transaction[:amount] + ) do |transaction_record| + transaction_record.currency = transaction[:currency] + transaction_record.name = transaction[:name] + end +end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb new file mode 100644 index 00000000..eb3eccdc --- /dev/null +++ b/test/controllers/transactions_controller_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class TransactionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:bob) + @transaction = transactions(:one) + end + + test "should get index" do + get transactions_url + assert_response :success + end + + test "should get new" do + get new_transaction_url + assert_response :success + end + + test "should create transaction" do + assert_difference("Transaction.count") do + post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } } + end + + assert_redirected_to transaction_url(Transaction.last) + end + + test "should show transaction" do + get transaction_url(@transaction) + assert_response :success + end + + test "should get edit" do + get edit_transaction_url(@transaction) + assert_response :success + end + + test "should update transaction" do + patch transaction_url(@transaction), params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } } + assert_redirected_to transaction_url(@transaction) + end + + test "should destroy transaction" do + assert_difference("Transaction.count", -1) do + delete transaction_url(@transaction) + end + + assert_redirected_to transactions_url + end +end diff --git a/test/fixtures/transactions.yml b/test/fixtures/transactions.yml new file mode 100644 index 00000000..35735abc --- /dev/null +++ b/test/fixtures/transactions.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + date: 2024-02-23 + amount: 9.99 + currency: MyString + account: dylan_checking + +two: + name: MyString + date: 2024-02-20 + amount: 9.99 + currency: MyString + account: dylan_checking diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb new file mode 100644 index 00000000..dc48590c --- /dev/null +++ b/test/models/transaction_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class TransactionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end