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:
+
+
+ <% holding.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% 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" %>
+
+
+
+
+
+ Security |
+ Quantity |
+ Current Price |
+ Current Value |
+ Cost Basis |
+ Gain/Loss |
+
+
+
+ <% @portfolio.holdings.each do |holding| %>
+
+ <%= 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) %> |
+
+ <% end %>
+
+
+
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