Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def index
base_scope = authorized_scope(EventRegistration.all)
filtered = base_scope.search_by_params(params)
@event_registrations_count = filtered.size
@event_registrations = filtered.includes(registrant: [ :user, { avatar_attachment: :blob } ], event: :event_forms).paginate(page: params[:page], per_page: per_page)
@event_registrations = filtered.includes(registrant: [ :user, { avatar_attachment: :blob } ], event: :event_forms, continuing_education_registrations: :professional_license).paginate(page: params[:page], per_page: per_page)
@events = Event.order(start_date: :desc)
@organizations = authorized_scope(Organization.all, as: :affiliated).order(:name)
@filtered_event = Event.find_by(id: params[:event_id]) if params[:event_id].present?
Expand Down Expand Up @@ -60,6 +60,7 @@ def create
authorize! @event_registration

if @event_registration.save
reconcile_ce_registration if params[:ce].present?
respond_to do |format|
format.html {
redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to])
Expand Down Expand Up @@ -87,6 +88,7 @@ def update
@event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email }

if @event_registration.save
reconcile_ce_registration if params[:ce].present? && allowed_to?(:manage?, with: EventRegistrationPolicy)
respond_to do |format|
format.turbo_stream
format.html {
Expand Down Expand Up @@ -323,6 +325,27 @@ def toggle_checklist_step(step, completed)
end
end

# Reconcile the admin CE section (posted under the `ce` namespace) into the
# registration's CE registration + professional license. Unchecking "Requested"
# removes any CE registrations; otherwise the registrant's (single) CE
# registration is found-or-built against the licence for the typed number, with
# the editable hours applied.
def reconcile_ce_registration

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: The admin CE section posts under a separate ce namespace and is reconciled here rather than via accepts_nested_attributes_for. Editing a license number through the CE registration means touching a separate ProfessionalLicense (find-or-create), which nested attributes handle fragilely; this keeps it explicit and covers the common single-license case (multi-license is managed elsewhere).

ce = params[:ce]
unless ActiveModel::Type::Boolean.new.cast(ce[:requested])
@event_registration.continuing_education_registrations.destroy_all
return
end

license = ProfessionalLicense.find_or_create_for(
person: @event_registration.registrant, number: ce[:license_number].to_s.strip.presence
)
ce_registration = @event_registration.continuing_education_registrations.first_or_initialize
ce_registration.professional_license = license
ce_registration.hours = ce[:hours] if ce[:hours].present?
ce_registration.save!
end

# Strong parameters
def event_registration_params
params.require(:event_registration).permit(
Expand All @@ -331,9 +354,6 @@ def event_registration_params
:shoutout,
:intends_to_pay,
:expected_payment_method,
:ce_credit_requested,
:ce_hours_requested,
:ce_license_number,
:fee_note,
*EventRegistration::DAY_FIELDS,
organization_ids: [],
Expand Down
35 changes: 34 additions & 1 deletion app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,30 @@ def scholarship
@form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant)
end

# CE hours status: requested hours, amount owed, and license number.
# CE hours status: hours, amount owed, and license number.
def ce
end

# Public license-number entry from the CE callout. Sets the number on the
# registrant's (first) CE registration via a found-or-created license, mirrors
# it onto the registration's form answer, then returns to the callout. Plain
# full-page POST β€” no Turbo.
def update_ce_license

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Public license entry, authorized by the registration slug (the same bearer-token model as the other callouts). Plain full-page POST (no Turbo) β€” it sets the number on the first CE registration via find-or-create and mirrors it onto the form answer.

ce_registration = @event_registration.continuing_education_registrations.first
return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration

number = params[:license_number].to_s.strip.presence
ce_registration.professional_license = ProfessionalLicense.find_or_create_for(
person: @event_registration.registrant, number: number
)
ce_registration.save!
record_ce_license_answer(number)

redirect_to registration_ce_path(@event_registration.slug), notice: "License number saved."
rescue ActiveRecord::RecordInvalid
redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license number."
end

# Forms page: callout-card links to the W-9 and letter-to-supervisors
# resource pages (when seeded) and the invoice, each returning to forms.
def forms
Expand Down Expand Up @@ -103,6 +123,19 @@ def set_event
@event = @event_registration.event
end

# Keep the registrant's form submission in step with a license number entered
# on the callout, so the registration record shows the same value. A no-op
# when the form, field, or submission isn't present.
def record_ce_license_answer(number)
form = @event.registration_form
field = form&.form_fields&.find_by(field_identifier: "ce_license_number")
submission = form&.form_submissions&.find_by(person: @event_registration.registrant)
return unless field && submission

answer = submission.form_answers.find_or_initialize_by(form_field: field)
answer.update!(submitted_answer: number.to_s, question_name_when_answered: field.name)
end

# Builds the callout-card links shown on the forms page. The W-9 and the
# letter to supervisors open in their own resource page (preview + download)
# when seeded; the invoice is always available.
Expand Down
32 changes: 22 additions & 10 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,9 @@ def sample_ticket
invoice_requested: @show_all_options,
scholarship_requested: @show_all_options,
shoutout: @show_all_options,
ce_credit_requested: @show_all_options,
ce_hours_requested: @show_all_options ? 6 : nil,
ce_license_number: @show_all_options ? "SAMPLE-12345" : nil,
created_at: Time.current
)
build_sample_ce_registration if @show_all_options
end

def background
Expand All @@ -82,11 +80,12 @@ def registrants
authorize! @event, to: :registrants?
@event = @event.decorate
scope = @event.event_registrations
.includes(:comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.includes(:comments, :organizations, { continuing_education_registrations: :professional_license }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
scope = scope.ce_status(params[:ce_status]) if params[:ce_status].present?
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
scope = scope.registrant_state(params[:state]) if params[:state].present?
scope = scope.registrant_county(params[:county]) if params[:county].present?
Expand Down Expand Up @@ -127,7 +126,7 @@ def onboarding
authorize! @event, to: :registrants?
@event = @event.decorate
scope = @event.event_registrations
.includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, registrant: [ :user, { affiliations: :organization } ])
.includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, { continuing_education_registrations: :professional_license }, registrant: [ :user, { affiliations: :organization } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?

Expand Down Expand Up @@ -181,7 +180,7 @@ def details
def ce_hours
authorize! @event, to: :ce_hours?

if @event.ce_hours_details.blank?
unless @event.ce_hours_eligible?
redirect_to event_path(@event, reg: params[:reg].presence)
return
end
Expand Down Expand Up @@ -517,6 +516,19 @@ def copy_registration_form

private

# Build (unsaved) a CE registration on the sample ticket so the "Show all
# options" preview renders a populated CE card. Mirrors a complete, paid-looking
# CE record without touching the database.
def build_sample_ce_registration
sample_hours = @event.ce_hours || 6
license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345")
@event_registration.continuing_education_registrations.build(
professional_license: license,
hours: sample_hours,
amount_cents: (sample_hours.to_d * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round
)
end

# The registrations the admin checked on the recipient picker, narrowed to those
# we can actually email. Shared by the confirm interstitial and the send action
# so both operate on exactly the same set.
Expand Down Expand Up @@ -626,11 +638,11 @@ def onboarding_csv_row(registration, cost_required, day_count)
row << (scholarship ? helpers.dollars_from_cents(scholarship.amount_cents) : "")
row << (scholarship ? (scholarship.grant&.name.presence || "Unfunded") : "")
row << onboarding_scholarship_tasks_csv(registration)
ce_hours = registration.ce_hours_requested.to_i
row << (registration.ce_credit_requested? ? "Yes" : "No")
row << (ce_hours.positive? ? ce_hours : "")
ce_hours = registration.ce_hours_total
row << (registration.ce_requested? ? "Yes" : "No")
row << (ce_hours.positive? ? ContinuingEducationRegistration.format_hours(ce_hours) : "")
row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "")
row << registration.ce_license_number.to_s
row << registration.ce_license_numbers.join("; ")
EventRegistration::CHECKLIST_STEPS.each_key do |step|
row << (registration.checklist_step_completed?(step) ? "Yes" : "No")
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default class extends Controller {
updateAmount() {
if (!this.hasHoursTarget || !this.hasAmountTarget) return

const hours = Math.max(0, parseInt(this.hoursTarget.value, 10) || 0)
const hours = Math.max(0, parseFloat(this.hoursTarget.value) || 0)
const owed = hours * this.rateValue
// Mirror the dollars_from_cents helper: drop the cents when the amount is a
// whole number of dollars, keep two decimals otherwise.
Expand Down
5 changes: 5 additions & 0 deletions app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
module EventsHelper
# Display a CE hours figure without trailing zeros (e.g. "6", "1.5").
def ce_hours_display(hours)
ContinuingEducationRegistration.format_hours(hours)
end

# Stable anchor id for a registrant's row on the Onboarding matrix, so back-links
# from detail pages can scroll to (and highlight) the row they came from.
def onboarding_row_id(record_or_id)
Expand Down
13 changes: 12 additions & 1 deletion app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ class ContinuingEducationRegistration < ApplicationRecord
validates :hours, numericality: { greater_than_or_equal_to: 0 }
validate :license_belongs_to_registrant

# Display a CE hours figure without trailing zeros: "6", "1.5".
def self.format_hours(hours)
return if hours.blank?

number = hours.to_f
number == number.to_i ? number.to_i.to_s : number.to_s
end

def amount_owed_cents
[ amount_cents - paid_cents, 0 ].max
end
Expand Down Expand Up @@ -51,7 +59,10 @@ def sync_payment_status!
private

def default_hours_from_event
self.hours = event_registration&.event&.ce_hours if hours.blank? || hours.zero?
return if hours.present? && !hours.zero?

event_hours = event_registration&.event&.ce_hours
self.hours = event_hours if event_hours.present?
end

def calculate_amount
Expand Down
83 changes: 72 additions & 11 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,11 @@ class EventRegistration < ApplicationRecord
# only the first event.day_count of them are shown on the Onboarding tab.
DAY_FIELDS = (1..5).map { |day| "completed_day_#{day}" }.freeze

# Default price the registrant owes per requested continuing-education hour.
# The CE summary on the registration form multiplies it by ce_hours_requested.
CE_HOURLY_RATE_DOLLARS = 25

# Validations
validates :registrant_id, uniqueness: { scope: :event_id }
validates :event_id, presence: true
validates :status, inclusion: { in: ATTENDANCE_STATUSES }, allow_nil: false
validates :slug, uniqueness: true, allow_nil: true
validates :ce_hours_requested, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true

# Scopes
scope :registrant_name, ->(registrant_name) { joins(:registrant).where(
Expand Down Expand Up @@ -146,6 +141,30 @@ class EventRegistration < ApplicationRecord
else all
end
}
# Filter by CE state. "needs_license" is derived (a CE registration sits on a
# placeholder license); the rest match a ContinuingEducationRegistration#status.
scope :ce_status, ->(value) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: needs_license is derived (a CE registration on a placeholder license, number IS NULL); the other options match a ContinuingEducationRegistration#status. EXISTS subqueries so a registration with several CE registrations matches if any one qualifies.

case value
when "needs_license"
where(<<~SQL.squish)
EXISTS (
SELECT 1 FROM continuing_education_registrations cer
JOIN professional_licenses pl ON pl.id = cer.professional_license_id
WHERE cer.event_registration_id = event_registrations.id
AND pl.number IS NULL
)
SQL
when *ContinuingEducationRegistration::STATUSES
where(<<~SQL.squish, value)
EXISTS (
SELECT 1 FROM continuing_education_registrations cer
WHERE cer.event_registration_id = event_registrations.id
AND cer.status = ?
)
SQL
else all
end
}
scope :keyword, ->(term) {
return none if term.blank?

Expand Down Expand Up @@ -184,6 +203,9 @@ def self.search_by_params(params)
.where(event_registration_organizations: { organization_id: params[:organization_id] })
.distinct
end
if params[:ce_status].present?
registrations = registrations.ce_status(params[:ce_status])
end
registrations
end

Expand Down Expand Up @@ -299,15 +321,54 @@ def discount_sum
allocations.where(source_type: "Discount").sum(:amount)
end

# True when the registrant has supplied a CE license number.
def ce_license_provided?
ce_license_number.present?
# CE is now tracked as one or more ContinuingEducationRegistration records,
# each against a professional license. These aggregate across them so callers
# (callouts, onboarding, CSV) read a single registration-level figure.
def ce_requested?
if ce_registrations_in_memory?
return continuing_education_registrations.any?
end
continuing_education_registrations.exists?
end

def ce_hours_total
if ce_registrations_in_memory?
return continuing_education_registrations.sum { |c| c.hours.to_d }
end
continuing_education_registrations.sum(:hours)
end

# What the registrant owes for their requested CE hours, in cents, at the
# default hourly rate. Zero when no hours were requested.
def ce_amount_owed_cents
ce_hours_requested.to_i * CE_HOURLY_RATE_DOLLARS * 100
if ce_registrations_in_memory?
return continuing_education_registrations.sum { |c| c.amount_cents.to_i }
end
continuing_education_registrations.sum(:amount_cents)
end

# True only when every CE registration has a known license number on file.
def ce_license_provided?
return false unless ce_requested?

continuing_education_registrations.all? { |c| c.professional_license&.number_known? }
end

# True when CE is requested and every CE registration is fully paid.
def ce_paid_in_full?
return false unless ce_requested?

continuing_education_registrations.all?(&:paid_in_full?)
end

# License numbers on file across this registration's CE registrations.
def ce_license_numbers
continuing_education_registrations.filter_map { |c| c.professional_license&.number }
end

# Read CE registrations from the in-memory collection rather than the DB when
# it's already loaded or this registration isn't persisted (e.g. the unsaved
# sample-ticket preview builds CE registrations without saving).
def ce_registrations_in_memory?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: The aggregators read the in-memory collection when the registration is a new_record? so the unsaved sample-ticket preview (which builds a CE registration without saving) still renders a populated CE card β€” otherwise exists? would query the DB with a nil id and report no CE.

continuing_education_registrations.loaded? || new_record?
end

def joinable?
Expand Down
12 changes: 4 additions & 8 deletions app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,10 @@ class FormField < ApplicationRecord

# Specify options scoped to a single field by its field_identifier, rather than
# to an option label everywhere it appears (SPECIFY_OPTION_PLACEHOLDERS). The
# CE-interest question's "Yes" reveals a "How many CE hours?" box that only
# makes sense there β€” a bare "Yes" anywhere else must stay a plain choice. The
# typed value folds into the answer as "Yes: <hours>", which the registration
# service parses onto EventRegistration#ce_hours_requested. The identifier
# matches EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER.
FIELD_SPECIFY_OPTION_PLACEHOLDERS = {
"ce_credit_interest" => { "Yes" => "How many CE hours?" }
}.freeze
# CE-interest question once revealed a "How many CE hours?" box here, but CE
# hours now come from the event, so the question is a plain Yes/No. Kept as the
# general mechanism for any future field-scoped specify box.
FIELD_SPECIFY_OPTION_PLACEHOLDERS = {}.freeze

# Fallback character ceilings applied when a free-form field has no explicit
# max_characters set. This is a safety net against pathological submissions
Expand Down
Loading