chore: init commit
in worker.../repo/GITFOLDER.zip is the .git folder.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Announcement < ApplicationRecord
|
||||
has_rich_text :content
|
||||
|
||||
validates :title, presence: true, length: { maximum: 255 }
|
||||
validates :content, presence: true
|
||||
|
||||
before_save :unfeature_other_announcements, if: :will_save_change_to_featured?
|
||||
|
||||
scope :latest, -> { order(created_at: :desc) }
|
||||
|
||||
def self.current
|
||||
find_by(featured: true)
|
||||
end
|
||||
|
||||
def excerpt(limit: 150)
|
||||
content&.to_plain_text&.truncate(limit) || ""
|
||||
end
|
||||
|
||||
def published_at
|
||||
created_at # Alias for semantic clarity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unfeature_other_announcements
|
||||
return unless featured? && will_save_change_to_featured?(to: true)
|
||||
|
||||
currently_featured = self.class.where(featured: true).where.not(id: id).first
|
||||
currently_featured&.update(featured: false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
125
worker-toolkit-stocks-in-the-future/repo/app/models/classroom.rb
Normal file
125
worker-toolkit-stocks-in-the-future/repo/app/models/classroom.rb
Normal file
@@ -0,0 +1,125 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Classroom < ApplicationRecord
|
||||
MIN_GRADE = 5
|
||||
MAX_GRADE = 8
|
||||
GRADE_RANGE = (MIN_GRADE..MAX_GRADE).to_a.freeze
|
||||
|
||||
belongs_to :school_year
|
||||
has_one :school, through: :school_year
|
||||
has_one :year, through: :school_year
|
||||
|
||||
delegate :name, to: :school, prefix: :school
|
||||
delegate :name, to: :year, prefix: :year
|
||||
|
||||
has_many :users, dependent: :nullify
|
||||
has_many :teacher_classrooms, dependent: :destroy
|
||||
has_many :teachers, through: :teacher_classrooms
|
||||
has_many :classroom_enrollments, dependent: :destroy
|
||||
has_many :enrolled_students, through: :classroom_enrollments, source: :student
|
||||
has_many :students, -> { kept }, class_name: "Student", inverse_of: :classroom, dependent: :nullify
|
||||
has_many :classroom_grades, dependent: :destroy
|
||||
has_many :grades, through: :classroom_grades
|
||||
has_many :grade_books, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validate :school_year_presence
|
||||
validate :grade_level
|
||||
|
||||
after_create :create_gradebooks_for_quarters
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
scope :archived, -> { where(archived: true) }
|
||||
scope :order_by_name, ->(direction = :asc) { reorder(name: direction) }
|
||||
scope :order_by_student_count, lambda { |direction = :asc|
|
||||
dir = direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
||||
|
||||
joins("LEFT OUTER JOIN users ON users.classroom_id = classrooms.id AND users.type = 'Student'")
|
||||
.group(:id)
|
||||
.order(Arel.sql("COUNT(users.id) #{dir}"))
|
||||
}
|
||||
scope :order_by_total_earnings, lambda { |direction = :asc|
|
||||
dir = direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
||||
|
||||
joins("LEFT OUTER JOIN users ON users.classroom_id = classrooms.id AND users.type = 'Student'")
|
||||
.joins("LEFT OUTER JOIN portfolios ON portfolios.user_id = users.id")
|
||||
.joins("LEFT OUTER JOIN portfolio_transactions ON portfolio_transactions.portfolio_id = portfolios.id")
|
||||
.group(:id)
|
||||
.order(Arel.sql("COALESCE(SUM(portfolio_transactions.amount_cents), 0) #{dir}"))
|
||||
}
|
||||
|
||||
# Apply sorting based on sort column param
|
||||
# @param collection [ActiveRecord::Relation] The collection to sort (optional)
|
||||
# @param sort_column [String] The column to sort by
|
||||
# @param direction [Symbol] :asc or :desc
|
||||
# @return [ActiveRecord::Relation] The sorted collection
|
||||
def self.apply_sorting(collection = nil, sort_column = nil, direction = :asc)
|
||||
base_scope = collection || all
|
||||
|
||||
case sort_column
|
||||
when "name"
|
||||
base_scope.order_by_name(direction)
|
||||
when "student_count"
|
||||
base_scope.order_by_student_count(direction)
|
||||
when "total_earnings"
|
||||
base_scope.order_by_total_earnings(direction)
|
||||
else
|
||||
base_scope.order_by_name(:asc)
|
||||
end
|
||||
end
|
||||
|
||||
# Get currently enrolled students (students with active enrollments)
|
||||
#
|
||||
# @return [ActiveRecord::Relation<Student>]
|
||||
def current_students
|
||||
enrolled_students.joins(:classroom_enrollments)
|
||||
.merge(classroom_enrollments.current)
|
||||
.distinct
|
||||
end
|
||||
|
||||
# Get historically enrolled students (students with past enrollments)
|
||||
#
|
||||
# @return [ActiveRecord::Relation<Student>]
|
||||
def historical_students
|
||||
enrolled_students.joins(:classroom_enrollments)
|
||||
.merge(classroom_enrollments.historical)
|
||||
.distinct
|
||||
end
|
||||
|
||||
def grades_display
|
||||
values = classroom_grades
|
||||
.joins(:grade)
|
||||
.pluck("grades.level")
|
||||
.uniq
|
||||
.sort
|
||||
return if values.empty?
|
||||
|
||||
if values.one?
|
||||
values.first.ordinalize
|
||||
elsif continuous?(values)
|
||||
"#{values.first.ordinalize}-#{values.last.ordinalize}"
|
||||
else
|
||||
values.map(&:ordinalize).join(", ")
|
||||
end
|
||||
end
|
||||
|
||||
def continuous?(values)
|
||||
values.each_cons(2).all? { |a, b| b == a + 1 }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_gradebooks_for_quarters
|
||||
school_year.quarters.each do |quarter|
|
||||
GradeBook.find_or_create_by!(quarter: quarter, classroom: self)
|
||||
end
|
||||
end
|
||||
|
||||
def grade_level
|
||||
errors.add(:grades, "must have at least one grade") if grades.empty?
|
||||
end
|
||||
|
||||
def school_year_presence
|
||||
errors.add(:school_year_id, :blank) if school_year_id.blank? && school_year.nil?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Represents a student's enrollment in a classroom, supporting multiple
|
||||
# enrollments over time with historical tracking.
|
||||
#
|
||||
# A student can have multiple enrollment records for the same classroom
|
||||
# (e.g., enrolled in Fall 2023, then again in Spring 2024), and can be
|
||||
# enrolled in multiple classrooms simultaneously.
|
||||
#
|
||||
# @example Find a student's current enrollments
|
||||
# student.classroom_enrollments.current
|
||||
#
|
||||
# @example Find a student's enrollment history
|
||||
# student.classroom_enrollments.historical
|
||||
#
|
||||
# @example Get a student's primary classroom
|
||||
# student.primary_enrollment&.classroom
|
||||
class ClassroomEnrollment < ApplicationRecord
|
||||
belongs_to :student, class_name: "Student"
|
||||
belongs_to :classroom
|
||||
|
||||
validates :enrolled_at, presence: true
|
||||
validate :unenrolled_at_after_enrolled_at
|
||||
validate :only_one_primary_per_student, if: :primary?
|
||||
|
||||
scope :current, -> { where(unenrolled_at: nil) }
|
||||
scope :historical, -> { where.not(unenrolled_at: nil) }
|
||||
scope :primary_enrollment, -> { where(primary: true) }
|
||||
scope :for_student, ->(student) { where(student: student) }
|
||||
scope :for_classroom, ->(classroom) { where(classroom: classroom) }
|
||||
|
||||
# Mark this enrollment as the primary enrollment for the student
|
||||
# and demote any other primary enrollments
|
||||
#
|
||||
# @return [self]
|
||||
def make_primary!
|
||||
transaction do
|
||||
ClassroomEnrollment.where(student: student, primary: true)
|
||||
.where.not(id: id)
|
||||
.find_each { |enrollment| enrollment.update!(primary: false) }
|
||||
update!(primary: true)
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
# Unenroll the student from this classroom
|
||||
#
|
||||
# @param at [DateTime] when the student was unenrolled (defaults to now)
|
||||
# @return [self]
|
||||
def unenroll!(at: Time.current)
|
||||
update!(unenrolled_at: at, primary: false)
|
||||
self
|
||||
end
|
||||
|
||||
# Check if this enrollment is currently active
|
||||
#
|
||||
# @return [Boolean]
|
||||
def current?
|
||||
unenrolled_at.nil?
|
||||
end
|
||||
|
||||
# Check if this enrollment is historical (no longer active)
|
||||
#
|
||||
# @return [Boolean]
|
||||
def historical?
|
||||
!current?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unenrolled_at_after_enrolled_at
|
||||
return if unenrolled_at.blank? || enrolled_at.blank?
|
||||
return if unenrolled_at >= enrolled_at
|
||||
|
||||
errors.add(:unenrolled_at, "must be after enrolled_at")
|
||||
end
|
||||
|
||||
def only_one_primary_per_student
|
||||
return if student_id.blank?
|
||||
|
||||
existing = ClassroomEnrollment.where(student_id: student_id, primary: true)
|
||||
.where.not(id: id)
|
||||
.exists?
|
||||
|
||||
errors.add(:primary, "student can only have one primary enrollment") if existing
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ClassroomGrade < ApplicationRecord
|
||||
belongs_to :classroom
|
||||
belongs_to :grade
|
||||
|
||||
validates :grade_id, uniqueness: { scope: :classroom_id }
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module UrlHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Rails.application.routes.url_helpers
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EarningsSummary
|
||||
attr_reader :portfolio
|
||||
|
||||
def initialize(portfolio)
|
||||
@portfolio = portfolio
|
||||
end
|
||||
|
||||
def attendance_earnings_cents
|
||||
sum_by_reason(:attendance_earnings)
|
||||
end
|
||||
|
||||
def reading_earnings_cents
|
||||
sum_by_reason(:reading_earnings)
|
||||
end
|
||||
|
||||
def math_earnings_cents
|
||||
sum_by_reason(:math_earnings)
|
||||
end
|
||||
|
||||
def awards_cents
|
||||
sum_by_reason(:awards)
|
||||
end
|
||||
|
||||
def total_earnings_cents
|
||||
attendance_earnings_cents + reading_earnings_cents + math_earnings_cents + awards_cents
|
||||
end
|
||||
|
||||
def transaction_fees_cents
|
||||
sum_by_reason(:transaction_fees)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sum_by_reason(reason)
|
||||
portfolio.portfolio_transactions
|
||||
.deposits
|
||||
.where(reason: reason)
|
||||
.sum(:amount_cents)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Grade < ApplicationRecord
|
||||
has_many :classroom_grades, dependent: :restrict_with_error
|
||||
has_many :classrooms, through: :classroom_grades
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :level, presence: true, uniqueness: true
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GradeBook < ApplicationRecord
|
||||
belongs_to :quarter
|
||||
belongs_to :classroom
|
||||
has_many :grade_entries, dependent: :destroy
|
||||
|
||||
enum :status, {
|
||||
draft: "draft",
|
||||
verified: "verified",
|
||||
completed: "completed"
|
||||
}
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GradeEntry < ApplicationRecord
|
||||
belongs_to :grade_book
|
||||
belongs_to :user
|
||||
|
||||
delegate :username, to: :user
|
||||
|
||||
EARNINGS_PER_DAY_ATTENDANCE = 20
|
||||
EARNINGS_FOR_A_GRADE = 3_00
|
||||
EARNINGS_FOR_B_GRADE = 2_00
|
||||
EARNINGS_FOR_IMPROVED_GRADE = 2_00
|
||||
EARNINGS_FOR_PERFECT_ATTENDANCE = 1_00
|
||||
|
||||
GRADE_OPTIONS = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D", "F"].freeze
|
||||
|
||||
def earnings_for_attendance
|
||||
return 0 if attendance_days.blank?
|
||||
|
||||
attendance_days * EARNINGS_PER_DAY_ATTENDANCE
|
||||
end
|
||||
|
||||
def earnings_for_math
|
||||
grade_based_earnings(math_grade)
|
||||
end
|
||||
|
||||
def earnings_for_reading
|
||||
grade_based_earnings(reading_grade)
|
||||
end
|
||||
|
||||
def attendance_perfect_earnings
|
||||
return 0 unless is_perfect_attendance
|
||||
|
||||
EARNINGS_FOR_PERFECT_ATTENDANCE
|
||||
end
|
||||
|
||||
def math_improvement_earnings(previous_entry)
|
||||
return 0 unless previous_entry
|
||||
return 0 if math_grade.blank? || previous_entry.math_grade.blank?
|
||||
|
||||
improved_grade?(math_grade, previous_entry.math_grade) ? EARNINGS_FOR_IMPROVED_GRADE : 0
|
||||
end
|
||||
|
||||
def reading_improvement_earnings(previous_entry)
|
||||
return 0 unless previous_entry
|
||||
return 0 if reading_grade.blank? || previous_entry.reading_grade.blank?
|
||||
|
||||
improved_grade?(reading_grade, previous_entry.reading_grade) ? EARNINGS_FOR_IMPROVED_GRADE : 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def grade_based_earnings(grade)
|
||||
case grade
|
||||
when "A+", "A", "A-"
|
||||
EARNINGS_FOR_A_GRADE
|
||||
when "B+", "B", "B-"
|
||||
EARNINGS_FOR_B_GRADE
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def improved_grade?(current_grade,
|
||||
previous_grade)
|
||||
GRADE_OPTIONS.index(current_grade) < GRADE_OPTIONS.index(previous_grade)
|
||||
end
|
||||
end
|
||||
208
worker-toolkit-stocks-in-the-future/repo/app/models/order.rb
Normal file
208
worker-toolkit-stocks-in-the-future/repo/app/models/order.rb
Normal file
@@ -0,0 +1,208 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Order < ApplicationRecord
|
||||
include ApplicationHelper
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :stock
|
||||
belongs_to :portfolio_stock, optional: true
|
||||
belongs_to :portfolio_transaction, optional: true
|
||||
|
||||
enum :status, { pending: 0, completed: 1, canceled: 2 }, default: :pending
|
||||
enum :action, { buy: "buy", sell: "sell" }
|
||||
|
||||
validates :shares, presence: true, numericality: { greater_than: 0 }
|
||||
|
||||
validate :sufficient_funds_for_buy_when_update, on: :update, if: -> { buy? }
|
||||
validate :order_is_pending, on: :update
|
||||
|
||||
# Now runs when creating orders or when user changes the share amount.
|
||||
# Skips during status changes (pending→completed) to avoid false errors.
|
||||
validate :sufficient_shares_for_sell,
|
||||
if: -> { sell? && validate_share_availability? },
|
||||
on: %i[create update]
|
||||
validate :sufficient_funds_for_buy, if: -> { buy? }, on: :create
|
||||
validate :prevent_archived_stock_purchase, if: -> { buy? }, on: %i[create update]
|
||||
validate :trading_enabled_for_classroom, on: :create
|
||||
|
||||
after_update :update_portfolio_transaction_for_pending_order
|
||||
|
||||
delegate :portfolio, to: :user
|
||||
|
||||
scope :buy, -> { where(action: :buy) }
|
||||
scope :sell, -> { where(action: :sell) }
|
||||
|
||||
scope :pending, -> { where(status: :pending) }
|
||||
scope :completed, -> { where(status: :completed) }
|
||||
scope :canceled, -> { where(status: :canceled) }
|
||||
|
||||
scope :for_student, ->(student) { where(user: student) }
|
||||
scope :for_teacher, lambda { |teacher|
|
||||
joins(user: :classroom).where(users: { classroom: teacher.classrooms })
|
||||
}
|
||||
|
||||
scope :order_by_created_at, lambda { |direction = :asc|
|
||||
reorder(created_at: safe_direction(direction))
|
||||
}
|
||||
scope :order_by_username, lambda { |direction = :asc|
|
||||
joins(:user).reorder(users: { username: safe_direction(direction) })
|
||||
}
|
||||
scope :order_by_classroom, lambda { |direction = :asc|
|
||||
joins(user: :classroom)
|
||||
.reorder(classrooms: { name: safe_direction(direction) })
|
||||
}
|
||||
scope :order_by_stock, lambda { |direction = :asc|
|
||||
joins(:stock).reorder(stocks: { ticker: safe_direction(direction) })
|
||||
}
|
||||
scope :order_by_price_per_share, lambda { |direction = :asc|
|
||||
joins(:stock)
|
||||
.reorder(stocks: { price_cents: safe_direction(direction) })
|
||||
}
|
||||
scope :order_by_shares, lambda { |direction = :asc|
|
||||
reorder(shares: safe_direction(direction))
|
||||
}
|
||||
scope :order_by_status, lambda { |direction = :asc|
|
||||
reorder(status: safe_direction(direction))
|
||||
}
|
||||
scope :order_by_total_cost, lambda { |direction = :asc|
|
||||
dir = safe_direction(direction)
|
||||
|
||||
expression = Arel.sql("stocks.price_cents * orders.shares")
|
||||
joins(:stock)
|
||||
.reorder(dir == :desc ? expression.desc : expression.asc)
|
||||
}
|
||||
|
||||
# Apply sorting based on sort column param
|
||||
# @param collection [ActiveRecord::Relation] The collection to sort (optional)
|
||||
# @param sort_column [String] The column to sort by
|
||||
# @param direction [Symbol] :asc or :desc
|
||||
# @return [ActiveRecord::Relation] The sorted collection
|
||||
def self.apply_sorting(collection = nil, sort_column = nil, direction = :asc)
|
||||
base_scope = collection || all
|
||||
sorting_method = SORTING_METHODS[sort_column]
|
||||
|
||||
return base_scope.order_by_created_at(:desc) unless sorting_method
|
||||
|
||||
base_scope.send(sorting_method, direction)
|
||||
end
|
||||
|
||||
# Map of sort column names to scope method names
|
||||
SORTING_METHODS = {
|
||||
"created_at" => :order_by_created_at,
|
||||
"username" => :order_by_username,
|
||||
"classroom" => :order_by_classroom,
|
||||
"stock" => :order_by_stock,
|
||||
"price_per_share" => :order_by_price_per_share,
|
||||
"shares" => :order_by_shares,
|
||||
"status" => :order_by_status,
|
||||
"total_cost" => :order_by_total_cost
|
||||
}.freeze
|
||||
|
||||
def cancel!
|
||||
update(status: :canceled)
|
||||
end
|
||||
|
||||
def purchase_cost
|
||||
stock.price_cents * shares
|
||||
end
|
||||
|
||||
def existing_transaction_type
|
||||
order_transaction_type = portfolio_transaction&.transaction_type&.to_sym
|
||||
order_transaction_type == :debit ? "buy" : "sell"
|
||||
end
|
||||
|
||||
def self.safe_direction(direction)
|
||||
direction.to_s.downcase == "desc" ? :desc : :asc
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def number_of_shares_valid_for_calculations?
|
||||
shares.is_a?(Numeric) && shares.positive?
|
||||
end
|
||||
|
||||
def sufficient_shares_for_sell
|
||||
return unless number_of_shares_valid_for_calculations?
|
||||
|
||||
current_shares = user.portfolio&.shares_owned(stock_id) || 0
|
||||
return unless shares > current_shares
|
||||
|
||||
formatted_shares = (current_shares % 1).zero? ? current_shares.to_i : current_shares
|
||||
errors.add(:base, "Cannot sell more shares than you own (#{formatted_shares} available)")
|
||||
end
|
||||
|
||||
def sufficient_funds_for_buy
|
||||
return unless number_of_shares_valid_for_calculations?
|
||||
|
||||
current_balance_cents = (user.portfolio&.cash_balance || 0) * 100
|
||||
total_cost = purchase_cost + transaction_fee
|
||||
return if (current_balance_cents - total_cost) >= 0
|
||||
|
||||
formatted_balance = format_money(current_balance_cents)
|
||||
formatted_cost = format_money(total_cost)
|
||||
errors.add(:shares, "Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}")
|
||||
end
|
||||
|
||||
def transaction_fee
|
||||
user.orders.pending.where.not(id:).exists? ? 0 : PortfolioTransaction::TRANSACTION_FEE_CENTS
|
||||
end
|
||||
|
||||
def sufficient_funds_for_buy_when_update
|
||||
return unless number_of_shares_valid_for_calculations?
|
||||
return unless shares_changed?
|
||||
|
||||
previous_shares = shares_was
|
||||
refund = (stock.price_cents * previous_shares) + PortfolioTransaction::TRANSACTION_FEE_CENTS
|
||||
|
||||
current_balance_cents = ((user.portfolio&.cash_balance || 0) * 100) + refund
|
||||
total_cost = purchase_cost + transaction_fee
|
||||
return if (current_balance_cents - total_cost) >= 0
|
||||
|
||||
formatted_balance = format_money(current_balance_cents)
|
||||
formatted_cost = format_money(total_cost)
|
||||
errors.add(
|
||||
:shares,
|
||||
"Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}"
|
||||
)
|
||||
end
|
||||
|
||||
def order_is_pending
|
||||
prev_status, = status_change_to_be_saved
|
||||
|
||||
return if pending? || prev_status.nil? || prev_status == "pending"
|
||||
|
||||
errors.add(:base, "Cannot update non-pending orders")
|
||||
end
|
||||
|
||||
def update_portfolio_transaction_for_pending_order
|
||||
return unless pending? && portfolio_transaction.present?
|
||||
|
||||
portfolio_transaction.amount_cents = purchase_cost
|
||||
portfolio_transaction.transaction_type = translated_transaction_type
|
||||
portfolio_transaction.save
|
||||
end
|
||||
|
||||
def translated_transaction_type
|
||||
buy? ? :debit : :credit
|
||||
end
|
||||
|
||||
def prevent_archived_stock_purchase
|
||||
return unless stock&.archived?
|
||||
|
||||
errors.add(:stock, "Cannot purchase shares of archived stocks")
|
||||
end
|
||||
|
||||
# Only validate share availability when shares quantity changes,
|
||||
# not during status-only updates (pending → completed)
|
||||
def validate_share_availability?
|
||||
return true if new_record?
|
||||
|
||||
shares_changed?
|
||||
end
|
||||
|
||||
def trading_enabled_for_classroom
|
||||
return if user&.classroom&.trading_enabled?
|
||||
|
||||
errors.add(:base, "Trading is currently disabled for your classroom")
|
||||
end
|
||||
end
|
||||
105
worker-toolkit-stocks-in-the-future/repo/app/models/portfolio.rb
Normal file
105
worker-toolkit-stocks-in-the-future/repo/app/models/portfolio.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Portfolio < ApplicationRecord
|
||||
include ::UrlHelpers
|
||||
|
||||
belongs_to :user
|
||||
validate :user_must_be_student
|
||||
|
||||
delegate :username, :student?, :school_name, :trading_enabled?, to: :user
|
||||
|
||||
has_many :portfolio_transactions, dependent: :destroy
|
||||
has_many :portfolio_stocks, dependent: :destroy
|
||||
has_many :stocks, through: :portfolio_stocks
|
||||
has_many :portfolio_snapshots, dependent: :destroy
|
||||
|
||||
def cash_balance
|
||||
cash_on_hand
|
||||
end
|
||||
|
||||
def path
|
||||
portfolio_path(self)
|
||||
end
|
||||
|
||||
def shares_owned(stock_id)
|
||||
portfolio_stocks.where(stock_id: stock_id).sum(:shares)
|
||||
end
|
||||
|
||||
def positions
|
||||
PortfolioPosition.for_portfolio(self)
|
||||
end
|
||||
|
||||
def calculate_total_value_cents
|
||||
cash_on_hand_in_cents + holdings_value_cents
|
||||
end
|
||||
|
||||
def calculate_total_value
|
||||
calculate_total_value_cents / 100.0
|
||||
end
|
||||
|
||||
def total_portfolio_worth
|
||||
calculate_total_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
holdings_value_cents / 100.0
|
||||
end
|
||||
|
||||
def holdings_value_cents
|
||||
portfolio_stocks.joins(:stock).sum(
|
||||
"portfolio_stocks.shares * stocks.price_cents"
|
||||
)
|
||||
end
|
||||
|
||||
def chart_data
|
||||
snapshots = portfolio_snapshots.order(date: :asc).last(12)
|
||||
|
||||
snapshots.map do |snapshot|
|
||||
{
|
||||
label: snapshot.date.strftime("%b %Y"),
|
||||
value: snapshot.current_worth
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cash_on_hand
|
||||
cash_on_hand_in_cents / 100.0
|
||||
end
|
||||
|
||||
def cash_on_hand_in_cents
|
||||
credits = total_credits + total_deposits
|
||||
debits = total_debits + total_withdrawals + total_fees + pending_transaction_fee
|
||||
credits - debits
|
||||
end
|
||||
|
||||
def total_withdrawals
|
||||
portfolio_transactions.withdrawals.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_deposits
|
||||
portfolio_transactions.deposits.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_fees
|
||||
portfolio_transactions.fees.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_credits
|
||||
portfolio_transactions.credits.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_debits
|
||||
pending_orders_amount = user.orders.pending.buy.sum(&:purchase_cost) || 0
|
||||
portfolio_transactions.debits.sum(:amount_cents) + pending_orders_amount
|
||||
end
|
||||
|
||||
def pending_transaction_fee
|
||||
user.orders.pending.exists? ? PortfolioTransaction::TRANSACTION_FEE_CENTS : 0
|
||||
end
|
||||
|
||||
def user_must_be_student
|
||||
errors.add(:user, "must be a student") unless user&.student?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PortfolioPosition
|
||||
attr_reader :stock, :shares, :portfolio, :change_amount, :total_return_amount
|
||||
|
||||
delegate :current_price, :price_cents, :ticker, to: :stock, prefix: :stock
|
||||
|
||||
def initialize(stock:, shares:, portfolio: nil, financial_data: {})
|
||||
@stock = stock
|
||||
@shares = shares
|
||||
@portfolio = portfolio
|
||||
@change_amount = financial_data[:change_amount]
|
||||
@total_return_amount = financial_data[:total_return_amount]
|
||||
end
|
||||
|
||||
def current_value
|
||||
shares * stock_current_price
|
||||
end
|
||||
|
||||
def current_value_cents
|
||||
shares * stock_price_cents
|
||||
end
|
||||
|
||||
def self.for_portfolio(portfolio)
|
||||
Stock
|
||||
.joins(:portfolio_stocks)
|
||||
.where(portfolio_stocks: { portfolio_id: portfolio.id })
|
||||
.group("stocks.id")
|
||||
.having("SUM(portfolio_stocks.shares) > 0")
|
||||
.select(
|
||||
<<~SQL.squish
|
||||
stocks.*,
|
||||
SUM(portfolio_stocks.shares) AS total_shares,
|
||||
(stocks.price_cents / 100.0) * SUM(portfolio_stocks.shares) -
|
||||
SUM(portfolio_stocks.purchase_price * portfolio_stocks.shares) AS aggregated_change_amount,
|
||||
(stocks.price_cents / 100.0) * SUM(portfolio_stocks.shares) AS aggregated_total_return
|
||||
SQL
|
||||
)
|
||||
.map { |result| build_position(result, portfolio) }
|
||||
end
|
||||
|
||||
def self.build_position(result, portfolio)
|
||||
new(
|
||||
stock: result,
|
||||
shares: result.total_shares,
|
||||
portfolio: portfolio,
|
||||
financial_data: {
|
||||
change_amount: result.aggregated_change_amount,
|
||||
total_return_amount: result.aggregated_total_return
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private_class_method :build_position
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PortfolioSnapshot < ApplicationRecord
|
||||
belongs_to :portfolio
|
||||
|
||||
validates :date, presence: true
|
||||
validates :worth_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :portfolio_id, uniqueness: { scope: :date }
|
||||
|
||||
def current_worth
|
||||
worth_cents / 100.0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# NOTE: for calculations, use `PortfolioPosition` as an aggregate.
|
||||
class PortfolioStock < ApplicationRecord
|
||||
belongs_to :portfolio
|
||||
belongs_to :stock
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PortfolioTransaction < ApplicationRecord
|
||||
TRANSACTION_FEE_CENTS = 1_00
|
||||
# deposit/witdrawal is for cash transactions ie grades and attendance
|
||||
# credit/debit is for stock transactions ie buy/sell stocks
|
||||
enum :transaction_type, { deposit: 0, withdrawal: 1, credit: 2, debit: 3, fee: 4 }
|
||||
|
||||
enum :reason, {
|
||||
math_earnings: 0,
|
||||
reading_earnings: 1,
|
||||
attendance_earnings: 2,
|
||||
grade_earnings: 3, # Deprecated, will be removed in future
|
||||
transaction_fees: 4,
|
||||
awards: 5,
|
||||
administrative_adjustments: 6
|
||||
}, allow_nil: true
|
||||
|
||||
belongs_to :portfolio
|
||||
has_one :order, dependent: :destroy
|
||||
|
||||
scope :deposits, -> { where(transaction_type: :deposit) }
|
||||
scope :debits, -> { where(transaction_type: :debit) }
|
||||
scope :credits, -> { where(transaction_type: :credit) }
|
||||
scope :withdrawals, -> { where(transaction_type: :withdrawal) }
|
||||
scope :fees, -> { where(transaction_type: :fee) }
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Quarter < ApplicationRecord
|
||||
belongs_to :school_year
|
||||
has_many :grade_books, dependent: :restrict_with_error
|
||||
|
||||
validates :number,
|
||||
presence: true,
|
||||
inclusion: { in: 1..4 },
|
||||
uniqueness: { scope: :school_year_id }
|
||||
|
||||
scope :ordered, -> { order(:number) }
|
||||
|
||||
def next
|
||||
return first_quarter_of_next_year if number == 4
|
||||
|
||||
school_year.quarters.ordered.find_by(number: number + 1)
|
||||
end
|
||||
|
||||
def previous
|
||||
return last_quarter_of_previous_year if number == 1
|
||||
|
||||
school_year.quarters.ordered.find_by(number: number - 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def first_quarter_of_next_year
|
||||
@first_quarter_of_next_year ||= begin
|
||||
next_year = school_year.year.next_year
|
||||
|
||||
if next_year
|
||||
Quarter.joins(school_year: %i[school year])
|
||||
.where(
|
||||
school_years: { school: school_year.school },
|
||||
years: { id: next_year.id },
|
||||
number: 1
|
||||
)
|
||||
.first
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def last_quarter_of_previous_year
|
||||
@last_quarter_of_previous_year ||= begin
|
||||
previous_year = school_year.year.previous_year
|
||||
|
||||
if previous_year
|
||||
Quarter.joins(school_year: %i[school year])
|
||||
.where(
|
||||
school_years: { school: school_year.school },
|
||||
years: { id: previous_year.id },
|
||||
number: 4
|
||||
)
|
||||
.first
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class School < ApplicationRecord
|
||||
has_many :school_years, dependent: :restrict_with_error
|
||||
has_many :years, through: :school_years
|
||||
|
||||
validates :name, presence: true
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SchoolYear < ApplicationRecord
|
||||
belongs_to :school
|
||||
belongs_to :year
|
||||
has_many :classrooms, dependent: :restrict_with_error
|
||||
has_many :quarters, dependent: :destroy
|
||||
|
||||
delegate :name, to: :school, prefix: :school
|
||||
delegate :name, to: :year, prefix: :year
|
||||
|
||||
after_create :create_quarters
|
||||
|
||||
def name
|
||||
"#{school_name} (#{year_name})"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_quarters
|
||||
(1..4).each do |n|
|
||||
quarters.create!(name: "Quarter #{n}", number: n)
|
||||
end
|
||||
end
|
||||
end
|
||||
41
worker-toolkit-stocks-in-the-future/repo/app/models/stock.rb
Normal file
41
worker-toolkit-stocks-in-the-future/repo/app/models/stock.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Stock < ApplicationRecord
|
||||
has_many :portfolio_stocks, dependent: :restrict_with_error
|
||||
has_many :orders, dependent: :restrict_with_error
|
||||
|
||||
validates :ticker, presence: true
|
||||
validates(
|
||||
:company_website,
|
||||
format: {
|
||||
with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
|
||||
allow_blank: true
|
||||
}
|
||||
)
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
scope :archived, -> { where(archived: true) }
|
||||
|
||||
def current_price
|
||||
price_cents.to_f / 100
|
||||
end
|
||||
|
||||
def yesterday_price
|
||||
return current_price if yesterday_price_cents.nil?
|
||||
|
||||
yesterday_price_cents.to_f / 100
|
||||
end
|
||||
|
||||
def percentage_change
|
||||
return 0.0 if yesterday_price_cents.nil? || yesterday_price_cents.zero?
|
||||
|
||||
((current_price - yesterday_price) / yesterday_price) * 100
|
||||
end
|
||||
|
||||
def percentage_change_formatted
|
||||
return "0.00%" if percentage_change.zero?
|
||||
|
||||
formatted = format("%.2f%%", percentage_change.abs)
|
||||
percentage_change.positive? ? "+#{formatted}" : "-#{formatted}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Student < User
|
||||
has_many :classroom_enrollments, dependent: :destroy
|
||||
has_many :classrooms, through: :classroom_enrollments
|
||||
|
||||
# Ensure students have nil email by default (not empty string)
|
||||
after_initialize :set_default_email, if: :new_record?
|
||||
after_create :ensure_portfolio
|
||||
after_create :create_initial_enrollment, if: -> { classroom_id.present? }
|
||||
|
||||
delegate :path, to: :portfolio, prefix: true, allow_nil: true
|
||||
|
||||
# Get the student's current (active) enrollments
|
||||
#
|
||||
# @return [ActiveRecord::Relation<ClassroomEnrollment>]
|
||||
def current_enrollments
|
||||
classroom_enrollments.current
|
||||
end
|
||||
|
||||
# Get the student's current (active) classrooms
|
||||
#
|
||||
# @return [ActiveRecord::Relation<Classroom>]
|
||||
def current_classrooms
|
||||
classrooms.joins(:classroom_enrollments)
|
||||
.merge(classroom_enrollments.current)
|
||||
.distinct
|
||||
end
|
||||
|
||||
# Get the student's primary enrollment
|
||||
#
|
||||
# @return [ClassroomEnrollment, nil]
|
||||
def primary_enrollment
|
||||
classroom_enrollments.primary_enrollment.current.first
|
||||
end
|
||||
|
||||
# Get the student's primary classroom (for backward compatibility)
|
||||
# Falls back to classroom_id if no primary enrollment exists
|
||||
#
|
||||
# @return [Classroom, nil]
|
||||
def primary_classroom
|
||||
primary_enrollment&.classroom || classroom
|
||||
end
|
||||
|
||||
# Enroll the student in a classroom
|
||||
#
|
||||
# @param classroom [Classroom] the classroom to enroll in
|
||||
# @param enrolled_at [DateTime] when the student enrolled (defaults to now)
|
||||
# @param primary [Boolean] whether this should be the primary enrollment
|
||||
# @return [ClassroomEnrollment]
|
||||
def enroll_in!(classroom, enrolled_at: Time.current, primary: false)
|
||||
enrollment = classroom_enrollments.create!(
|
||||
classroom: classroom,
|
||||
enrolled_at: enrolled_at,
|
||||
primary: false
|
||||
)
|
||||
enrollment.make_primary! if primary
|
||||
enrollment
|
||||
end
|
||||
|
||||
# Unenroll the student from a classroom
|
||||
#
|
||||
# @param classroom [Classroom] the classroom to unenroll from
|
||||
# @param unenrolled_at [DateTime] when the student was unenrolled (defaults to now)
|
||||
# @return [void]
|
||||
def unenroll_from!(classroom, unenrolled_at: Time.current)
|
||||
current_enrollments.for_classroom(classroom).each do |enrollment|
|
||||
enrollment.unenroll!(at: unenrolled_at)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_email
|
||||
self.email = nil if email.blank?
|
||||
end
|
||||
|
||||
def ensure_portfolio
|
||||
create_portfolio! if portfolio.blank?
|
||||
end
|
||||
|
||||
def create_initial_enrollment
|
||||
# Create initial enrollment based on classroom_id
|
||||
# Set as primary since it's the first enrollment
|
||||
enroll_in!(classroom, enrolled_at: created_at, primary: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Teacher < User
|
||||
attr_accessor :school_id
|
||||
|
||||
has_many :teacher_classrooms, dependent: :destroy
|
||||
has_many :classrooms, through: :teacher_classrooms
|
||||
|
||||
before_validation :sync_username_from_email
|
||||
|
||||
def display_name
|
||||
name.presence || email&.split("@")&.first || "Teacher"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_username_from_email
|
||||
self.username = email if email.present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TeacherClassroom < ApplicationRecord
|
||||
belongs_to :teacher, class_name: "Teacher"
|
||||
belongs_to :classroom
|
||||
|
||||
validates :teacher_id, uniqueness: { scope: :classroom_id }
|
||||
end
|
||||
83
worker-toolkit-stocks-in-the-future/repo/app/models/user.rb
Normal file
83
worker-toolkit-stocks-in-the-future/repo/app/models/user.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class User < ApplicationRecord
|
||||
include Discard::Model
|
||||
|
||||
def destroy(*)
|
||||
soft_delete_guard
|
||||
discard
|
||||
end
|
||||
|
||||
def destroy!(*)
|
||||
soft_delete_guard
|
||||
discard
|
||||
end
|
||||
|
||||
def really_destroy!
|
||||
ActiveRecord::Base.instance_method(:destroy).bind(self).call
|
||||
end
|
||||
|
||||
belongs_to :classroom, optional: true
|
||||
|
||||
# Allow calling `user.school` (used in portfolio view) via the classroom's associated school.
|
||||
# This prevents undefined method errors for Student records without directly adding a belongs_to.
|
||||
delegate :school, to: :classroom, allow_nil: true
|
||||
delegate :name, to: :school, prefix: :school, allow_nil: true
|
||||
delegate :trading_enabled?, to: :classroom, allow_nil: true
|
||||
|
||||
has_one :portfolio, dependent: :destroy
|
||||
accepts_nested_attributes_for :portfolio
|
||||
has_many :orders, dependent: :destroy
|
||||
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
|
||||
validates :email, uniqueness: true, presence: false, allow_blank: true
|
||||
validates :username, presence: true, uniqueness: true
|
||||
validates :type, inclusion: { in: %w[User Student Teacher] }
|
||||
|
||||
scope :students, -> { where(type: "Student") }
|
||||
scope :teachers, -> { where(type: "Teacher") }
|
||||
scope :admins, -> { where(admin: true) }
|
||||
|
||||
def student?
|
||||
type == "Student"
|
||||
end
|
||||
|
||||
def teacher?
|
||||
type == "Teacher"
|
||||
end
|
||||
|
||||
def teacher_or_admin?
|
||||
teacher? || admin?
|
||||
end
|
||||
|
||||
def display_name
|
||||
username.presence || email&.split("@")&.first || "User"
|
||||
end
|
||||
|
||||
def email_required?
|
||||
teacher? || admin?
|
||||
end
|
||||
|
||||
def email_changed?
|
||||
false
|
||||
end
|
||||
|
||||
def holding?(stock)
|
||||
portfolio&.shares_owned(stock.id)&.positive?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def soft_delete_guard
|
||||
return if Rails.env.production?
|
||||
|
||||
raise <<~MSG.squish
|
||||
❌ Hard delete attempted on #{self.class}. Use #discard instead,
|
||||
or #really_destroy! if you are ABSOLUTELY sure you need a hard delete.
|
||||
MSG
|
||||
end
|
||||
end
|
||||
42
worker-toolkit-stocks-in-the-future/repo/app/models/year.rb
Normal file
42
worker-toolkit-stocks-in-the-future/repo/app/models/year.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Year < ApplicationRecord
|
||||
has_many :school_years, dependent: :destroy
|
||||
has_many :schools, through: :school_years
|
||||
has_many :classrooms, through: :school_years
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
|
||||
scope :ordered_by_start_year, -> { order(Arel.sql("CAST(SUBSTRING(name FROM 1 FOR 4) AS INTEGER) DESC")) }
|
||||
|
||||
def self.current_school_year(date = Date.current)
|
||||
name = if date.month <= 6
|
||||
"#{date.year - 1} - #{date.year}"
|
||||
else
|
||||
"#{date.year} - #{date.year + 1}"
|
||||
end
|
||||
where(name: name)
|
||||
end
|
||||
|
||||
def previous_year
|
||||
@previous_year || Year.find_by(name: "#{start_year_value - 1} - #{start_year_value}")
|
||||
end
|
||||
|
||||
def next_year
|
||||
@next_year || Year.find_by(name: "#{end_year_value} - #{end_year_value + 1}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_year_value
|
||||
@start_year_value ||= parsed_year_values.first
|
||||
end
|
||||
|
||||
def end_year_value
|
||||
@end_year_value ||= parsed_year_values.last
|
||||
end
|
||||
|
||||
def parsed_year_values
|
||||
@parsed_year_values ||= name.split(" - ").map(&:to_i)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user