From 379103d229f2f1d06b8955f13a055f07e066ffb7 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 2 Jan 2024 11:56:49 -0600 Subject: [PATCH] First rough pass at tracking holdings --- .env.example | 1 + .gitignore | 1 + Gemfile | 4 + Gemfile.lock | 20 +++ README.md | 1 + app/controllers/holdings_controller.rb | 85 ++++++++++++ app/helpers/holdings_helper.rb | 2 + app/jobs/sync_security_job.rb | 45 +++++++ app/models/holding.rb | 5 + app/models/portfolio.rb | 1 + app/models/security.rb | 5 + app/models/security_price.rb | 3 + app/models/user.rb | 1 + app/views/holdings/_form.html.erb | 37 ++++++ app/views/holdings/_holding.html.erb | 32 +++++ app/views/holdings/edit.html.erb | 8 ++ app/views/holdings/index.html.erb | 14 ++ app/views/holdings/new.html.erb | 7 + app/views/holdings/show.html.erb | 15 +++ app/views/portfolios/show.html.erb | 34 ++++- config/application.rb | 2 + config/routes.rb | 10 +- db/migrate/20240102161615_create_good_jobs.rb | 88 +++++++++++++ .../20240102162023_add_admin_to_users.rb | 5 + .../20240102162707_create_securities.rb | 15 +++ .../20240102163116_create_security_prices.rb | 14 ++ db/migrate/20240102164204_create_holdings.rb | 14 ++ ...173603_add_last_synced_at_to_securities.rb | 5 + ..._security_date_index_to_security_prices.rb | 5 + db/schema.rb | 124 +++++++++++++++++- test/controllers/holdings_controller_test.rb | 48 +++++++ test/fixtures/holdings.yml | 15 +++ test/fixtures/securities.yml | 15 +++ test/fixtures/security_prices.yml | 17 +++ test/jobs/sync_security_job_test.rb | 7 + test/models/holding_test.rb | 7 + test/models/security_price_test.rb | 7 + test/models/security_test.rb | 7 + test/system/holdings_test.rb | 49 +++++++ 39 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 app/controllers/holdings_controller.rb create mode 100644 app/helpers/holdings_helper.rb create mode 100644 app/jobs/sync_security_job.rb create mode 100644 app/models/holding.rb create mode 100644 app/models/security.rb create mode 100644 app/models/security_price.rb create mode 100644 app/views/holdings/_form.html.erb create mode 100644 app/views/holdings/_holding.html.erb create mode 100644 app/views/holdings/edit.html.erb create mode 100644 app/views/holdings/index.html.erb create mode 100644 app/views/holdings/new.html.erb create mode 100644 app/views/holdings/show.html.erb create mode 100644 db/migrate/20240102161615_create_good_jobs.rb create mode 100644 db/migrate/20240102162023_add_admin_to_users.rb create mode 100644 db/migrate/20240102162707_create_securities.rb create mode 100644 db/migrate/20240102163116_create_security_prices.rb create mode 100644 db/migrate/20240102164204_create_holdings.rb create mode 100644 db/migrate/20240102173603_add_last_synced_at_to_securities.rb create mode 100644 db/migrate/20240102174350_add_security_date_index_to_security_prices.rb create mode 100644 test/controllers/holdings_controller_test.rb create mode 100644 test/fixtures/holdings.yml create mode 100644 test/fixtures/securities.yml create mode 100644 test/fixtures/security_prices.yml create mode 100644 test/jobs/sync_security_job_test.rb create mode 100644 test/models/holding_test.rb create mode 100644 test/models/security_price_test.rb create mode 100644 test/models/security_test.rb create mode 100644 test/system/holdings_test.rb diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..aeb942be --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +TWELVEDATA_KEY=YOUR_TWELVEDATA_KEY \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9b66b155..a5384cc8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # Ignore all environment files (except templates). /.env* !/.env*.erb +!/.env.example # Ignore all logfiles and tempfiles. /log/* diff --git a/Gemfile b/Gemfile index bc96ff3d..5e34647b 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,10 @@ gem "bootsnap", require: false # Authentication gem "devise" +# Data processing +gem "good_job" +gem "faraday" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ] diff --git a/Gemfile.lock b/Gemfile.lock index e581448d..47f1abbf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,9 +141,26 @@ GEM ruby2_keywords error_highlight (0.6.0) erubi (1.12.0) + et-orbi (1.2.7) + tzinfo + faraday (2.8.1) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-net_http (3.0.2) ffi (1.16.3) + fugit (1.9.0) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + good_job (3.21.5) + activejob (>= 6.0.0) + activerecord (>= 6.0.0) + concurrent-ruby (>= 1.0.2) + fugit (>= 1.1) + railties (>= 6.0.0) + thor (>= 0.14.1) hotwire-livereload (1.3.0) actioncable (>= 6.0.0) listen (>= 3.0.0) @@ -210,6 +227,7 @@ GEM public_suffix (5.0.4) puma (6.4.0) nio4r (~> 2.0) + raabro (1.4.0) racc (1.7.3) rack (3.0.8) rack-session (2.0.0) @@ -334,6 +352,8 @@ DEPENDENCIES debug devise error_highlight (>= 0.4.0) + faraday + good_job hotwire-livereload importmap-rails jbuilder diff --git a/README.md b/README.md index 5fcc4a58..f5c1872c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ You'll need: - ruby >3 (specific version is in `Gemfile`) - postgresql (if using stock `config/database.yml`) +- TwelveData API key (free) - https://twelvedata.com ```shell cd maybe diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb new file mode 100644 index 00000000..cd3ef67d --- /dev/null +++ b/app/controllers/holdings_controller.rb @@ -0,0 +1,85 @@ +class HoldingsController < ApplicationController + before_action :authenticate_user! + before_action :set_holding, only: %i[ show edit update destroy ] + + # GET /holdings or /holdings.json + def index + @holdings = Holding.all + end + + # GET /holdings/1 or /holdings/1.json + def show + end + + # GET /holdings/new + def new + @portfolio = current_user.portfolios.find(params[:portfolio_id]) + @holding = @portfolio.holdings.new + end + + # GET /holdings/1/edit + def edit + end + + # POST /holdings or /holdings.json + def create + @portfolio = current_user.portfolios.find(params[:portfolio_id]) + + security = Security.find_by(symbol: params[:holding][:symbol]) + if security.nil? + security = Security.create(symbol: params[:holding][:symbol]) + SyncSecurityJob.perform_later(security.id) + end + + params[:holding].delete(:symbol) + + @holding = @portfolio.holdings.new(holding_params) + @holding.security_id = security.id + @holding.user_id = current_user.id + + respond_to do |format| + if @holding.save + format.html { redirect_to portfolio_path(@portfolio), notice: "Holding was successfully created." } + format.json { render :show, status: :created, location: @holding } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @holding.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /holdings/1 or /holdings/1.json + def update + respond_to do |format| + if @holding.update(holding_params) + format.html { redirect_to holding_url(@holding), notice: "Holding was successfully updated." } + format.json { render :show, status: :ok, location: @holding } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @holding.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /holdings/1 or /holdings/1.json + def destroy + @holding.destroy! + + respond_to do |format| + format.html { redirect_to holdings_url, notice: "Holding was successfully destroyed." } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_holding + @portfolio = current_user.portfolios.find(params[:portfolio_id]) + @holding = @portfolio.holdings.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def holding_params + params.require(:holding).permit(:symbol, :security_id, :value, :quantity, :cost_basis) + end +end diff --git a/app/helpers/holdings_helper.rb b/app/helpers/holdings_helper.rb new file mode 100644 index 00000000..1a2f96dd --- /dev/null +++ b/app/helpers/holdings_helper.rb @@ -0,0 +1,2 @@ +module HoldingsHelper +end diff --git a/app/jobs/sync_security_job.rb b/app/jobs/sync_security_job.rb new file mode 100644 index 00000000..cb074f34 --- /dev/null +++ b/app/jobs/sync_security_job.rb @@ -0,0 +1,45 @@ +class SyncSecurityJob < ApplicationJob + queue_as :default + + def perform(security_id) + security = Security.find_by(id: security_id) + return unless security + + profile_data = Faraday.get("https://api.twelvedata.com/profile?symbol=#{security.symbol}&apikey=#{ENV['TWELVEDATA_KEY']}") + profile_details = JSON.parse(profile_data.body) + + security.update( + name: profile_details['name'], + exchange: profile_details['exchange'], + mic_code: profile_details['mic_code'] + ) + + # Pull price history + earliest_date_connection = Faraday.get("https://api.twelvedata.com/earliest_timestamp?symbol=#{security.symbol}&interval=1day&apikey=#{ENV['TWELVEDATA_KEY']}") + + earliest_date = JSON.parse(earliest_date_connection.body)['datetime'] + + prices_connection = Faraday.get("https://api.twelvedata.com/time_series?apikey=#{ENV['TWELVEDATA_KEY']}&interval=1day&symbol=#{security.symbol}&start_date=#{earliest_date}&outputsize=5000") + + prices = JSON.parse(prices_connection.body)['values'] + + all_prices = [] + + prices.each do |price| + all_prices << { + security_id: security.id, + date: price['datetime'], + open: price['open'], + high: price['high'], + low: price['low'], + close: price['close'], + } + end + + all_prices.uniq! { |price| price[:date] } + + SecurityPrice.upsert_all(all_prices, unique_by: :index_security_prices_on_security_id_and_date) + + security.update(last_synced_at: DateTime.now) + end +end diff --git a/app/models/holding.rb b/app/models/holding.rb new file mode 100644 index 00000000..3811bf86 --- /dev/null +++ b/app/models/holding.rb @@ -0,0 +1,5 @@ +class Holding < ApplicationRecord + belongs_to :user + belongs_to :security + belongs_to :portfolio +end diff --git a/app/models/portfolio.rb b/app/models/portfolio.rb index 1abf1f0f..ea0f5f16 100644 --- a/app/models/portfolio.rb +++ b/app/models/portfolio.rb @@ -1,5 +1,6 @@ class Portfolio < ApplicationRecord belongs_to :user + has_many :holdings, dependent: :destroy validates :name, presence: true end diff --git a/app/models/security.rb b/app/models/security.rb new file mode 100644 index 00000000..5f899d15 --- /dev/null +++ b/app/models/security.rb @@ -0,0 +1,5 @@ +class Security < ApplicationRecord + has_many :holdings, dependent: :destroy + has_many :portfolios, through: :holdings + has_many :security_prices, dependent: :destroy +end diff --git a/app/models/security_price.rb b/app/models/security_price.rb new file mode 100644 index 00000000..77512411 --- /dev/null +++ b/app/models/security_price.rb @@ -0,0 +1,3 @@ +class SecurityPrice < ApplicationRecord + belongs_to :security +end diff --git a/app/models/user.rb b/app/models/user.rb index 51120e7f..6dfdf135 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,4 +4,5 @@ class User < ApplicationRecord :confirmable, :lockable, :timeoutable, :trackable has_many :portfolios, dependent: :destroy + has_many :holdings, through: :portfolios end diff --git a/app/views/holdings/_form.html.erb b/app/views/holdings/_form.html.erb new file mode 100644 index 00000000..e546b09c --- /dev/null +++ b/app/views/holdings/_form.html.erb @@ -0,0 +1,37 @@ +<%= form_with(model: [portfolio, holding], class: "contents") do |form| %> + <% if holding.errors.any? %> +
+

<%= pluralize(holding.errors.count, "error") %> prohibited this holding from being saved:

+ + +
+ <% end %> + +
+ <%= form.label :symbol, "Security (AAPL, MSFT, TSLA, etc.)" %> + <%= form.text_field :symbol, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :value, "Total holding value (in USD)" %> + <%= form.number_field :value, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :quantity, "Number of shares" %> + <%= form.number_field :quantity, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :cost_basis, "Cost basis (in USD)" %> + <%= form.number_field :cost_basis, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> +
+<% end %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb new file mode 100644 index 00000000..40583ef2 --- /dev/null +++ b/app/views/holdings/_holding.html.erb @@ -0,0 +1,32 @@ +
+

+ User: + <%= holding.user_id %> +

+ +

+ Security: + <%= holding.security_id %> +

+ +

+ Value: + <%= holding.value %> +

+ +

+ Quantity: + <%= holding.quantity %> +

+ +

+ Cost basis: + <%= holding.cost_basis %> +

+ + <% if action_name != "show" %> + <%= link_to "Show this holding", holding, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> + <%= link_to "Edit this holding", edit_holding_path(holding), class: "rounded-lg py-3 ml-2 px-5 bg-gray-100 inline-block font-medium" %> +
+ <% end %> +
diff --git a/app/views/holdings/edit.html.erb b/app/views/holdings/edit.html.erb new file mode 100644 index 00000000..36471b23 --- /dev/null +++ b/app/views/holdings/edit.html.erb @@ -0,0 +1,8 @@ +
+

Editing holding

+ + <%= render "form", holding: @holding %> + + <%= link_to "Show this holding", @holding, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> + <%= link_to "Back to holdings", holdings_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
diff --git a/app/views/holdings/index.html.erb b/app/views/holdings/index.html.erb new file mode 100644 index 00000000..5290836b --- /dev/null +++ b/app/views/holdings/index.html.erb @@ -0,0 +1,14 @@ +
+ <% if notice.present? %> +

<%= notice %>

+ <% end %> + +
+

Holdings

+ <%= link_to "New holding", new_holding_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> +
+ +
+ <%= render @holdings %> +
+
diff --git a/app/views/holdings/new.html.erb b/app/views/holdings/new.html.erb new file mode 100644 index 00000000..83fc4882 --- /dev/null +++ b/app/views/holdings/new.html.erb @@ -0,0 +1,7 @@ +
+

New holding

+ + <%= render "form", portfolio: @portfolio, holding: @holding %> + + <%= link_to "Back to holdings", portfolio_path(@portfolio), class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb new file mode 100644 index 00000000..9a166c8c --- /dev/null +++ b/app/views/holdings/show.html.erb @@ -0,0 +1,15 @@ +
+
+ <% if notice.present? %> +

<%= notice %>

+ <% end %> + + <%= render @holding %> + + <%= link_to "Edit this holding", edit_holding_path(@holding), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+ <%= button_to "Destroy this holding", holding_path(@holding), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %> +
+ <%= link_to "Back to holdings", holdings_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+
diff --git a/app/views/portfolios/show.html.erb b/app/views/portfolios/show.html.erb index 5a4d5db9..4e395908 100644 --- a/app/views/portfolios/show.html.erb +++ b/app/views/portfolios/show.html.erb @@ -1,4 +1,4 @@ -
+
<% if notice.present? %>

<%= notice %>

<% end %> @@ -14,4 +14,36 @@ <%= link_to "Back to portfolios", portfolios_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+ +
+

Holdings

+
+ <%= link_to "New holding", new_portfolio_holding_path(@portfolio), class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> +
+ + + + + + + + + + + + + + <% @portfolio.holdings.each do |holding| %> + + + + + + + + + <% end %> + +
SecurityQuantityCurrent PriceCurrent ValueCost BasisGain/Loss
<%= holding.security.symbol %><%= holding.quantity %><%= number_to_currency(holding.security.security_prices.order(date: :desc).first&.close) %><%= number_to_currency(holding.quantity * (holding.security.security_prices.order(date: :desc).first&.close || 0)) %><%= number_to_currency(holding.cost_basis) %><%= number_to_currency((holding.quantity * (holding.security.security_prices.order(date: :desc).first&.close || 0)) - holding.cost_basis) %>
+
diff --git a/config/application.rb b/config/application.rb index 861d75dd..0cd8e858 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,7 @@ module Maybe # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + config.active_job.queue_adapter = :good_job end end diff --git a/config/routes.rb b/config/routes.rb index 1370a095..422afae3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ Rails.application.routes.draw do - resources :portfolios - + authenticate :user, ->(user) { user.admin? } do + mount GoodJob::Engine => 'background' + end + + resources :portfolios do + resources :holdings + end + devise_for :users # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20240102161615_create_good_jobs.rb b/db/migrate/20240102161615_create_good_jobs.rb new file mode 100644 index 00000000..902cda1c --- /dev/null +++ b/db/migrate/20240102161615_create_good_jobs.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class CreateGoodJobs < ActiveRecord::Migration[7.2] + def change + # Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support + # enable_extension 'pgcrypto' + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :performed_at + t.datetime :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + t.datetime :cron_at + + t.uuid :batch_id + t.uuid :batch_callback_id + + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + t.integer :error_event, limit: 2 + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + t.integer :error_event, limit: 2 + end + + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + + add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at + add_index :good_jobs, [ :queue_name, :scheduled_at ], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, [ :active_job_id, :created_at ], name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, [ :cron_key, :created_at ], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond + add_index :good_jobs, [ :cron_key, :cron_at ], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond + add_index :good_jobs, [ :active_job_id ], name: :index_good_jobs_on_active_job_id + add_index :good_jobs, [ :finished_at ], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at + add_index :good_jobs, [ :priority, :created_at ], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished + add_index :good_jobs, [ :batch_id ], where: "batch_id IS NOT NULL" + add_index :good_jobs, [ :batch_callback_id ], where: "batch_callback_id IS NOT NULL" + + add_index :good_job_executions, [ :active_job_id, :created_at ], name: :index_good_job_executions_on_active_job_id_and_created_at + end +end diff --git a/db/migrate/20240102162023_add_admin_to_users.rb b/db/migrate/20240102162023_add_admin_to_users.rb new file mode 100644 index 00000000..60fee8ff --- /dev/null +++ b/db/migrate/20240102162023_add_admin_to_users.rb @@ -0,0 +1,5 @@ +class AddAdminToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :admin, :boolean, default: false + end +end diff --git a/db/migrate/20240102162707_create_securities.rb b/db/migrate/20240102162707_create_securities.rb new file mode 100644 index 00000000..e51f1dff --- /dev/null +++ b/db/migrate/20240102162707_create_securities.rb @@ -0,0 +1,15 @@ +class CreateSecurities < ActiveRecord::Migration[7.2] + def change + create_table :securities, id: :uuid do |t| + t.string :name + t.string :symbol + t.string :exchange + t.string :mic_code + t.string :currency_code + t.decimal :real_time_price, precision: 10, scale: 2 + t.datetime :real_time_price_updated_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240102163116_create_security_prices.rb b/db/migrate/20240102163116_create_security_prices.rb new file mode 100644 index 00000000..8f895604 --- /dev/null +++ b/db/migrate/20240102163116_create_security_prices.rb @@ -0,0 +1,14 @@ +class CreateSecurityPrices < ActiveRecord::Migration[7.2] + def change + create_table :security_prices, id: :uuid do |t| + t.references :security, null: false, foreign_key: true, type: :uuid + t.date :date, null: false + t.decimal :open, precision: 20, scale: 11 + t.decimal :high, precision: 20, scale: 11 + t.decimal :low, precision: 20, scale: 11 + t.decimal :close, precision: 20, scale: 11 + + t.timestamps + end + end +end diff --git a/db/migrate/20240102164204_create_holdings.rb b/db/migrate/20240102164204_create_holdings.rb new file mode 100644 index 00000000..dde33e8f --- /dev/null +++ b/db/migrate/20240102164204_create_holdings.rb @@ -0,0 +1,14 @@ +class CreateHoldings < ActiveRecord::Migration[7.2] + def change + create_table :holdings, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.references :security, null: false, foreign_key: true, type: :uuid + t.references :portfolio, null: false, foreign_key: true, type: :uuid + t.decimal :value, precision: 19, scale: 4 + t.decimal :quantity, precision: 36, scale: 18 + t.decimal :cost_basis, precision: 23, scale: 8 + + t.timestamps + end + end +end diff --git a/db/migrate/20240102173603_add_last_synced_at_to_securities.rb b/db/migrate/20240102173603_add_last_synced_at_to_securities.rb new file mode 100644 index 00000000..ec7213aa --- /dev/null +++ b/db/migrate/20240102173603_add_last_synced_at_to_securities.rb @@ -0,0 +1,5 @@ +class AddLastSyncedAtToSecurities < ActiveRecord::Migration[7.2] + def change + add_column :securities, :last_synced_at, :datetime + end +end diff --git a/db/migrate/20240102174350_add_security_date_index_to_security_prices.rb b/db/migrate/20240102174350_add_security_date_index_to_security_prices.rb new file mode 100644 index 00000000..d5c1ca01 --- /dev/null +++ b/db/migrate/20240102174350_add_security_date_index_to_security_prices.rb @@ -0,0 +1,5 @@ +class AddSecurityDateIndexToSecurityPrices < ActiveRecord::Migration[7.2] + def change + add_index :security_prices, [:security_id, :date], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 09d9bef0..b63dc05c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,102 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do +ActiveRecord::Schema[7.2].define(version: 2024_01_02_174350) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["active_job_id"], name: "index_good_jobs_on_active_job_id" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + + create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "security_id", null: false + t.uuid "portfolio_id", null: false + t.decimal "value", precision: 19, scale: 4 + t.decimal "quantity", precision: 36, scale: 18 + t.decimal "cost_basis", precision: 23, scale: 8 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["portfolio_id"], name: "index_holdings_on_portfolio_id" + t.index ["security_id"], name: "index_holdings_on_security_id" + t.index ["user_id"], name: "index_holdings_on_user_id" + end + create_table "portfolios", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id", null: false t.string "name" @@ -23,6 +114,32 @@ ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do t.index ["user_id"], name: "index_portfolios_on_user_id" end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.string "symbol" + t.string "exchange" + t.string "mic_code" + t.string "currency_code" + t.decimal "real_time_price", precision: 10, scale: 2 + t.datetime "real_time_price_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "last_synced_at" + end + + create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "security_id", null: false + t.date "date", null: false + t.decimal "open", precision: 20, scale: 11 + t.decimal "high", precision: 20, scale: 11 + t.decimal "low", precision: 20, scale: 11 + t.decimal "close", precision: 20, scale: 11 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["security_id", "date"], name: "index_security_prices_on_security_id_and_date", unique: true + t.index ["security_id"], name: "index_security_prices_on_security_id" + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -43,11 +160,16 @@ ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do t.datetime "locked_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "admin", default: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true end + add_foreign_key "holdings", "portfolios" + add_foreign_key "holdings", "securities" + add_foreign_key "holdings", "users" add_foreign_key "portfolios", "users" + add_foreign_key "security_prices", "securities" end diff --git a/test/controllers/holdings_controller_test.rb b/test/controllers/holdings_controller_test.rb new file mode 100644 index 00000000..e95c1a2d --- /dev/null +++ b/test/controllers/holdings_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class HoldingsControllerTest < ActionDispatch::IntegrationTest + setup do + @holding = holdings(:one) + end + + test "should get index" do + get holdings_url + assert_response :success + end + + test "should get new" do + get new_holding_url + assert_response :success + end + + test "should create holding" do + assert_difference("Holding.count") do + post holdings_url, params: { holding: { cost_basis: @holding.cost_basis, quantity: @holding.quantity, security_id: @holding.security_id, user_id: @holding.user_id, value: @holding.value } } + end + + assert_redirected_to holding_url(Holding.last) + end + + test "should show holding" do + get holding_url(@holding) + assert_response :success + end + + test "should get edit" do + get edit_holding_url(@holding) + assert_response :success + end + + test "should update holding" do + patch holding_url(@holding), params: { holding: { cost_basis: @holding.cost_basis, quantity: @holding.quantity, security_id: @holding.security_id, user_id: @holding.user_id, value: @holding.value } } + assert_redirected_to holding_url(@holding) + end + + test "should destroy holding" do + assert_difference("Holding.count", -1) do + delete holding_url(@holding) + end + + assert_redirected_to holdings_url + end +end diff --git a/test/fixtures/holdings.yml b/test/fixtures/holdings.yml new file mode 100644 index 00000000..ce00d8bb --- /dev/null +++ b/test/fixtures/holdings.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + security: one + value: 9.99 + quantity: 9.99 + cost_basis: 9.99 + +two: + user: two + security: two + value: 9.99 + quantity: 9.99 + cost_basis: 9.99 diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml new file mode 100644 index 00000000..ac78774b --- /dev/null +++ b/test/fixtures/securities.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + symbol: MyString + currency_code: MyString + real_time_price: 9.99 + real_time_price_updated_at: 2024-01-02 10:27:07 + +two: + name: MyString + symbol: MyString + currency_code: MyString + real_time_price: 9.99 + real_time_price_updated_at: 2024-01-02 10:27:07 diff --git a/test/fixtures/security_prices.yml b/test/fixtures/security_prices.yml new file mode 100644 index 00000000..0f892fb2 --- /dev/null +++ b/test/fixtures/security_prices.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + security: one + date: 2024-01-02 + open: 9.99 + high: 9.99 + low: 9.99 + close: 9.99 + +two: + security: two + date: 2024-01-02 + open: 9.99 + high: 9.99 + low: 9.99 + close: 9.99 diff --git a/test/jobs/sync_security_job_test.rb b/test/jobs/sync_security_job_test.rb new file mode 100644 index 00000000..e91cb028 --- /dev/null +++ b/test/jobs/sync_security_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SyncSecurityJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb new file mode 100644 index 00000000..f4cdde13 --- /dev/null +++ b/test/models/holding_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HoldingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/security_price_test.rb b/test/models/security_price_test.rb new file mode 100644 index 00000000..255084c1 --- /dev/null +++ b/test/models/security_price_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SecurityPriceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/security_test.rb b/test/models/security_test.rb new file mode 100644 index 00000000..8e82099f --- /dev/null +++ b/test/models/security_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SecurityTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/system/holdings_test.rb b/test/system/holdings_test.rb new file mode 100644 index 00000000..ad7d3e19 --- /dev/null +++ b/test/system/holdings_test.rb @@ -0,0 +1,49 @@ +require "application_system_test_case" + +class HoldingsTest < ApplicationSystemTestCase + setup do + @holding = holdings(:one) + end + + test "visiting the index" do + visit holdings_url + assert_selector "h1", text: "Holdings" + end + + test "should create holding" do + visit holdings_url + click_on "New holding" + + fill_in "Cost basis", with: @holding.cost_basis + fill_in "Quantity", with: @holding.quantity + fill_in "Security", with: @holding.security_id + fill_in "User", with: @holding.user_id + fill_in "Value", with: @holding.value + click_on "Create Holding" + + assert_text "Holding was successfully created" + click_on "Back" + end + + test "should update Holding" do + visit holding_url(@holding) + click_on "Edit this holding", match: :first + + fill_in "Cost basis", with: @holding.cost_basis + fill_in "Quantity", with: @holding.quantity + fill_in "Security", with: @holding.security_id + fill_in "User", with: @holding.user_id + fill_in "Value", with: @holding.value + click_on "Update Holding" + + assert_text "Holding was successfully updated" + click_on "Back" + end + + test "should destroy Holding" do + visit holding_url(@holding) + click_on "Destroy this holding", match: :first + + assert_text "Holding was successfully destroyed" + end +end