diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index bf669fbded..857538d18a 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -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? @@ -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]) @@ -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 { @@ -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 + 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( @@ -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: [], diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 83d13f10da..7f6a62a39c 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -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 + 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 @@ -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. diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index cb4e688ecf..962b0d4c6e 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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 @@ -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? @@ -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? @@ -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 @@ -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. @@ -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 diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js index 58b2528798..2d68f015a2 100644 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ b/app/frontend/javascript/controllers/ce_credit_requested_controller.js @@ -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. diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 25a706efda..98def1ddb3 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -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) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index cea7cca80b..07c4bd4b8c 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -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 @@ -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 diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 7267c591cf..c3249e5b2e 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -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( @@ -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) { + 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? @@ -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 @@ -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? + continuing_education_registrations.loaded? || new_record? end def joinable? diff --git a/app/models/form_field.rb b/app/models/form_field.rb index c8ee2feba6..7fc003ef67 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -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: ", 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 diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 6dc60d988a..a0be5a7a17 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -3,10 +3,14 @@ class PublicRegistration Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true) # Well-known field_identifier of the "magic" CE question seeded onto the - # registration form. Answering it "Yes" toggles the registration's - # ce_credit_requested flag. Kept here so the seed, service, and specs agree. + # registration form. Answering it "Yes" creates a ContinuingEducationRegistration + # (hours come from the event). Kept here so the seed, service, and specs agree. CE_CREDIT_INTEREST_IDENTIFIER = "ce_credit_interest".freeze + # Well-known field_identifier of the CE license-number question. Its answer + # seeds the registrant's ProfessionalLicense. + CE_LICENSE_NUMBER_IDENTIFIER = "ce_license_number".freeze + # Well-known field_identifier of the "Additional forms" multi-select question. # Checking "Invoice" / "W-9" toggles the registration's invoice_requested / # w9_requested flags, which the digital ticket reads to surface those downloads. @@ -62,7 +66,7 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - existing.update!(ce_credit_requested: true, ce_hours_requested: ce_hours_requested) if ce_credit_requested? + create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? payment_method = field_value("payment_method")&.strip @@ -78,6 +82,7 @@ def call end event_registration = create_event_registration(person) + create_ce_registration(event_registration, person) connect_organization(event_registration, organization) submission = create_form_submission(person) save_scholarship_submission(person) @@ -384,33 +389,31 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, - ce_credit_requested: ce_credit_requested?, - ce_hours_requested: ce_hours_requested, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence ) end - # The CE-interest answer, which "Yes" folds an hours specify box into as - # "Yes: ". Split off the leading label so a Yes/No check ignores the - # folded hours (see FormField::FIELD_SPECIFY_OPTION_PLACEHOLDERS). - def ce_answer_label - field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).first.to_s.strip + # Create the registrant's CE registration when they opt in, against a license + # found-or-created from the license-number answer (a placeholder license when + # none was given). Hours come from the event via the model. No-op when they + # didn't opt in or a CE registration already exists for this registration. + def create_ce_registration(event_registration, person) + return unless ce_credit_requested? + return if event_registration.continuing_education_registrations.exists? + + license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number) + event_registration.continuing_education_registrations.create!(professional_license: license) end # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - ce_answer_label.casecmp?("yes") + field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") end - # The CE hours typed into the "Yes" specify box, as a positive integer, or nil - # when CE was not requested or no valid hours were entered. - def ce_hours_requested - return unless ce_credit_requested? - - hours = field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).last.to_s.strip.to_i - hours.positive? ? hours : nil + def ce_license_number + field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence end # The "Additional forms" question is a multi-select, so its submitted value is diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 73ee8d6a88..e958512711 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -120,8 +120,8 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return unless registration.ce_credit_requested? - complete = registration.ce_hours_requested.present? && registration.ce_license_provided? + return unless registration.ce_requested? + complete = registration.ce_license_provided? Card.new(icon_class: "fa-solid fa-graduation-cap", color: "teal", title: event.ce_hours_details_label, subtitle: ce_hours_subtitle, @@ -134,8 +134,8 @@ def ce_hours_card end def ce_hours_subtitle - return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? - "Continuing education credit" + hours = ContinuingEducationRegistration.format_hours(event.ce_hours) + hours.present? ? "#{hours} hours" : "Continuing education credit" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an @@ -154,11 +154,9 @@ def ce_hours_badge(complete) amount_cents.positive? ? "#{amount} · #{needed}" : needed end + # Hours are set by the event now, so the only thing a requesting registrant can + # still be missing is their license number. def ce_missing_text - missing_hours = registration.ce_hours_requested.blank? - missing_license = !registration.ce_license_provided? - return "Hours & license number needed" if missing_hours && missing_license - return "Hours needed" if missing_hours "License number needed" end diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index adcc677b8a..ace7d450ac 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -109,16 +109,15 @@ def matches_account_status?(reg) reg.account_status == status end - # CE sub-statuses (missing license / missing hours) only make sense for someone - # who actually requested CE credit, so they're gated on that. "paid" has no - # CE-specific payment record yet, so it falls back to the registrant being paid - # in full. + # CE sub-statuses only make sense for someone who actually requested CE credit, + # so they're gated on that. Hours now come from the event (never "missing"), so + # the only outstanding detail is the license number; "paid" uses the CE-specific + # balance. def matches_ce_status?(reg) case @params[:ce_status].presence - when "requested" then reg.ce_credit_requested? - when "license_not_provided" then reg.ce_credit_requested? && !reg.ce_license_provided? - when "hours_not_provided" then reg.ce_credit_requested? && reg.ce_hours_requested.to_i <= 0 - when "paid" then reg.ce_credit_requested? && reg.paid_in_full? + when "requested" then reg.ce_requested? + when "license_not_provided" then reg.ce_requested? && !reg.ce_license_provided? + when "paid" then reg.ce_requested? && reg.ce_paid_in_full? else true end end diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 13a1091ad2..896e344c3d 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -222,16 +222,23 @@

CE credits

+ <% ce_reg = f.object.continuing_education_registrations.first %> + <% ce_requested = f.object.ce_requested? %> + <% ce_license = ce_reg&.professional_license&.number %> + <% ce_hours_value = ce_reg&.hours || f.object.event&.ce_hours %> + <%# CE is stored as its own ContinuingEducationRegistration(s), so these + inputs post under a separate `ce` namespace and the controller + reconciles them into a registration + professional license. ---- %>
+ data-ce-credit-requested-initial-value="<%= ce_requested %>" + data-ce-credit-requested-rate-value="<%= ContinuingEducationRegistration::HOURLY_RATE_DOLLARS %>"> -
"> +
">
- + "> - text-[0.55rem]"> - <%= f.object.ce_license_provided? ? "Provided" : "Not provided" %> + class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.65rem] font-medium <%= ce_license.present? ? "bg-teal-50 text-teal-700" : "bg-gray-100 text-gray-500" %>"> + text-[0.55rem]"> + <%= ce_license.present? ? "Provided" : "Not provided" %>
- <%= f.text_field :ce_license_number, - class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none", - placeholder: "Not provided", - data: { "ce-credit-requested-target": "license", action: "input->ce-credit-requested#refresh" } %> +
- - <%= f.number_field :ce_hours_requested, - min: 0, step: 1, - class: "w-28 rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm tabular-nums focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none", - placeholder: "0", - data: { "ce-credit-requested-target": "hours", action: "input->ce-credit-requested#refresh" } %> + +
Amount owed - ($<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>/hr) + ($<%= ContinuingEducationRegistration::HOURLY_RATE_DOLLARS %>/hr) <%= dollars_from_cents(f.object.ce_amount_owed_cents) %>
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index e7fb8876e2..d181cc10f2 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -471,6 +471,17 @@ title_placeholder: "CE hours", content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %> +
+ + +
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index 1db8ea304f..97632ff580 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -55,6 +55,18 @@ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+ +
+ <%= label_tag :ce_status, "CE", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :ce_status, + options_for_select( + [ [ "Needs license", "needs_license" ], [ "Requested", "requested" ], [ "Paid", "paid" ], [ "Issued", "issued" ], [ "Unawarded", "unawarded" ] ], + params[:ce_status] + ), + include_blank: "All registrants", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
<% end %> <% if @dashboard.states.any? %> diff --git a/app/views/events/_reminder_recipient_filters.html.erb b/app/views/events/_reminder_recipient_filters.html.erb index 2e9a5c618b..c96859fddf 100644 --- a/app/views/events/_reminder_recipient_filters.html.erb +++ b/app/views/events/_reminder_recipient_filters.html.erb @@ -59,7 +59,7 @@ <%= label_tag :ce_status, "CE", class: label_class %> <%= select_tag :ce_status, options_for_select( - [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], + [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Paid", "paid" ] ], params[:ce_status] ), include_blank: "Any CE status", class: select_class %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 25423fdb6f..e8074eb5a7 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,10 +1,12 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> - <% if @event_registration.ce_credit_requested? %> + <% if @event_registration.ce_requested? %> + <% ce_registration = @event_registration.continuing_education_registrations.first %> + <% license_number = ce_registration&.professional_license&.number %>

Status

- <% if @event_registration.paid_in_full? %> + <% if @event_registration.ce_paid_in_full? %> Paid @@ -17,31 +19,35 @@
-
Hours requested
- <% if @event_registration.ce_hours_requested.present? %> -
<%= @event_registration.ce_hours_requested %>
- <% else %> -
We don't have your requested hours yet.
- <% end %> +
Hours
+
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
-
Cost<% if @event_registration.ce_hours_requested.present? %> (<%= @event_registration.ce_hours_requested %> × $<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>)<% end %>
-
<%= @event_registration.ce_hours_requested.present? ? dollars_from_cents(@event_registration.ce_amount_owed_cents) : "—" %>
+
Cost
+
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
License number
- <% if @event_registration.ce_license_provided? %> -
<%= @event_registration.ce_license_number %>
+ <% if license_number.present? %> +
<%= license_number %>
<% else %> -
We don't have your license number on file yet.
+
Not on file yet.
<% end %>
- <% if @event_registration.ce_hours_requested.blank? || !@event_registration.ce_license_provided? %> -
- We don't have all of your CE details yet. Please <%= link_to "email us", contact_us_path, class: "underline font-medium" %> with your license number and the number of CE hours you'd like. + <%# Registrants supply (or correct) their license number here — a plain + full-page POST, no Turbo, so the page simply reloads with the saved value. %> + <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, + data: { turbo: false }, class: "mt-5 border-t border-gray-100 pt-5" do |form| %> + +
+ <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. LMFT 12345", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <%= form.submit "Save", class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %>
+

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

<% end %> <% else %>

You haven't requested continuing education credit for this training. CE hours are available for an additional fee — reach out to request credit.

diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index fbb0275718..8cc4d716e5 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -205,7 +205,7 @@ <% when :ce_requested %> - <% ce_requested = registration.ce_credit_requested? %> + <% ce_requested = registration.ce_requested? %> " data-sort-value="<%= ce_requested ? 1 : 0 %>"> <%= link_to edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:opacity-80", title: "Edit CE details", data: { turbo_frame: "_top" } do %> <% if ce_requested %> @@ -217,20 +217,20 @@ <% when :ce_hours %> - <% ce_hours = registration.ce_hours_requested.to_i %> + <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> - <%= link_to (ce_hours.positive? ? ce_hours : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_hours.positive? ? ce_hours_display(ce_hours) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_amount %> <% ce_cents = registration.ce_amount_owed_cents %> <%= ce_cents.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_cents %>"> - <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE owed (hours × $#{EventRegistration::CE_HOURLY_RATE_DOLLARS})", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE owed (hours × $#{ContinuingEducationRegistration::HOURLY_RATE_DOLLARS})", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_license %> - <% ce_license = registration.ce_license_number %> - text-sm" data-sort-value="<%= ce_license.to_s.downcase %>"> + <% ce_license = registration.ce_license_numbers.join(", ") %> + text-sm" data-sort-value="<%= ce_license.downcase %>"> <%= link_to (ce_license.presence || "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_license.present? ? "text-gray-700" : "text-gray-400"}", data: { turbo_frame: "_top" } %> diff --git a/config/routes.rb b/config/routes.rb index 529c0cc97e..617170356c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,6 +97,7 @@ get "registration/:slug/payment", to: "events/callouts#payment", as: :registration_payment get "registration/:slug/certificate", to: "events/callouts#certificate", as: :registration_certificate get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce + post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license get "registration/:slug/forms", to: "events/callouts#forms", as: :registration_forms get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource diff --git a/db/migrate/20260625162312_remove_ce_columns_from_event_registrations.rb b/db/migrate/20260625162312_remove_ce_columns_from_event_registrations.rb new file mode 100644 index 0000000000..029e38859d --- /dev/null +++ b/db/migrate/20260625162312_remove_ce_columns_from_event_registrations.rb @@ -0,0 +1,16 @@ +class RemoveCeColumnsFromEventRegistrations < ActiveRecord::Migration[8.1] + # CE is now tracked as ContinuingEducationRegistration records, so these flat + # columns are obsolete. No data to preserve (the CE form was never used in + # production). + def up + remove_column :event_registrations, :ce_credit_requested, if_exists: true + remove_column :event_registrations, :ce_hours_requested, if_exists: true + remove_column :event_registrations, :ce_license_number, if_exists: true + end + + def down + add_column :event_registrations, :ce_credit_requested, :boolean, null: false, default: false unless column_exists?(:event_registrations, :ce_credit_requested) + add_column :event_registrations, :ce_hours_requested, :integer unless column_exists?(:event_registrations, :ce_hours_requested) + add_column :event_registrations, :ce_license_number, :string unless column_exists?(:event_registrations, :ce_license_number) + end +end diff --git a/db/schema.rb b/db/schema.rb index 08ea6cc4bc..12b4c7fd81 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_25_152708) do +ActiveRecord::Schema[8.1].define(version: 2026_06_25_162312) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -481,9 +481,6 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "ce_credit_requested", default: false, null: false - t.integer "ce_hours_requested" - t.string "ce_license_number" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false t.boolean "completed_day_2", default: false, null: false diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 1e9da6fe34..e0c36491c3 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -155,8 +155,8 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose answer drives the -# resulting registration's ce_credit_requested flag (see +# The CE-interest "magic question": a single Yes/No whose "Yes" creates the +# registration's ContinuingEducationRegistration (see # EventRegistrationServices::PublicRegistration). Seeded straight onto the form # with its own section so the form builder's add/remove-section logic leaves it # alone, and carrying the well-known field_identifier the service keys off. A @@ -779,10 +779,11 @@ # so she can reach her training materials (the intends_to_pay scenario). Pairs # with Amy on this same event, who DOES have payments, for side-by-side review. if facilitator_training + facilitator_training.update!(ce_hours_eligible: true, ce_hours: 6) [ - { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true, ce_license_number: "LMFT 90210" }, { person: maria_j, status: "registered", invoice_requested: true, ce_credit_requested: true, intends_to_pay: true }, - { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true }, + { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true, ce_license_number: "LCSW 11223", ce_status: "issued" }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" }, { person: aisha_person, status: "registered", intends_to_pay: true } @@ -798,8 +799,9 @@ # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training + trauma_training.update!(ce_hours_eligible: true, ce_hours: 6) [ - { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true }, + { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true, ce_license_number: "LPCC 44556" }, { person: jessica_b, status: "registered", scholarship_requested: true, ce_credit_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -868,9 +870,16 @@ # existing DB (find_or_initialize no longer recreates these registrations). registration.w9_requested = data[:w9_requested] || false registration.invoice_requested = data[:invoice_requested] || false - registration.ce_credit_requested = data[:ce_credit_requested] || false registration.intends_to_pay = data[:intends_to_pay] || false registration.save! + + # CE opt-in becomes a ContinuingEducationRegistration against the registrant's + # license (a placeholder when no number is seeded). Hours come from the event. + if data[:ce_credit_requested] && registration.continuing_education_registrations.none? + license = ProfessionalLicense.find_or_create_for(person: data[:person], number: data[:ce_license_number]) + ce_registration = registration.continuing_education_registrations.create!(professional_license: license) + ce_registration.update!(status: data[:ce_status]) if data[:ce_status] + end end # Connect each multi-affiliation registrant's registration to a single one of diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 8426f5f677..a70ac60ac9 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -20,11 +20,9 @@ expect(helper.specify_placeholder(nil)).to be_nil end - it "returns the CE hours placeholder for 'Yes' only on the CE field" do + it "does not reveal a box for the CE question's 'Yes' (hours come from the event)" do ce_field = FormField.new(field_identifier: "ce_credit_interest") - other_field = FormField.new(field_identifier: "interested_in_more") - expect(helper.specify_placeholder("Yes", ce_field)).to eq("How many CE hours?") - expect(helper.specify_placeholder("Yes", other_field)).to be_nil + expect(helper.specify_placeholder("Yes", ce_field)).to be_nil end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index c330c32c41..fc314bbbd5 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -467,33 +467,39 @@ describe "continuing education" do let(:reg) { create(:event_registration) } + def add_ce(number: "LIC-123", hours: 4) + license = create(:professional_license, person: reg.registrant, number: number) + create(:continuing_education_registration, event_registration: reg, professional_license: license, hours: hours) + end + describe "#ce_amount_owed_cents" do - it "multiplies requested hours by the default hourly rate" do - reg.ce_hours_requested = 4 - expect(reg.ce_amount_owed_cents).to eq(4 * EventRegistration::CE_HOURLY_RATE_DOLLARS * 100) + it "sums the amount owed across the registration's CE registrations" do + add_ce(hours: 4) + expect(reg.ce_amount_owed_cents).to eq(4 * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) end - it "is zero when no hours are requested" do - reg.ce_hours_requested = nil + it "is zero when no CE is requested" do expect(reg.ce_amount_owed_cents).to eq(0) end end - describe "#ce_license_provided?" do - it "is true only when a license number is present" do - reg.ce_license_number = "LIC-123" - expect(reg).to be_ce_license_provided - reg.ce_license_number = "" - expect(reg).not_to be_ce_license_provided + describe "#ce_requested?" do + it "is true only once a CE registration exists" do + expect(reg).not_to be_ce_requested + add_ce + expect(reg.reload).to be_ce_requested end end - describe "ce_hours_requested validation" do - it "rejects negative or non-integer hours but allows nil" do - reg.ce_hours_requested = nil - expect(reg).to be_valid - reg.ce_hours_requested = -1 - expect(reg).not_to be_valid + describe "#ce_license_provided?" do + it "is true only when every CE registration has a known license number" do + add_ce(number: "LIC-123") + expect(reg.reload).to be_ce_license_provided + end + + it "is false when a CE registration sits on a placeholder license" do + add_ce(number: nil) + expect(reg.reload).not_to be_ce_license_provided end end end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index b937d699b0..4acece44a7 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -392,12 +392,12 @@ def selectable_field(type:, option_names:) expect(field.answer_inclusion_error("Foundation/Funder: ACME")).to eq("has an invalid selection") end - it "accepts the CE question's field-scoped 'Yes: ' specify answer" do + it "treats the CE question's 'Yes' as a plain choice (hours come from the event)" do field = selectable_field(type: :single_select_radio, option_names: %w[Yes No]) field.update!(field_identifier: "ce_credit_interest") expect(field.answer_inclusion_error("Yes")).to be_nil - expect(field.answer_inclusion_error("Yes: 6")).to be_nil expect(field.answer_inclusion_error("No")).to be_nil + expect(field.answer_inclusion_error("Yes: 6")).to eq("has an invalid selection") end it "treats a bare 'Yes' as a plain choice on other fields" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 5658219233..24efe844ee 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -34,6 +34,17 @@ expect(response.body).not_to include(existing_registration.registrant.first_name) end + it "filters registrations by ce_status" do + needs_license = create(:event_registration) + placeholder = create(:professional_license, :placeholder, person: needs_license.registrant) + create(:continuing_education_registration, event_registration: needs_license, professional_license: placeholder) + + get event_registrations_path(ce_status: "needs_license") + expect(response).to have_http_status(:success) + expect(response.body).to include(needs_license.registrant.first_name) + expect(response.body).not_to include(existing_registration.registrant.first_name) + end + it "exports CSV with headers and data only (no captions)" do get event_registrations_path, params: { format: :csv } @@ -247,20 +258,21 @@ expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "updates the CE credit requested flag" do + it "creates a CE registration when CE is requested" do patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1" } } + params: { event_registration: { status: existing_registration.status }, ce: { requested: "1" } } - expect(existing_registration.reload.ce_credit_requested).to be(true) + expect(existing_registration.reload.continuing_education_registrations.count).to eq(1) end - it "updates the CE hours and license number" do + it "sets the hours and license number on the CE registration" do patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1", ce_hours_requested: "5", ce_license_number: "LIC-987" } } + params: { event_registration: { status: existing_registration.status }, + ce: { requested: "1", hours: "5", license_number: "LIC-987" } } - existing_registration.reload - expect(existing_registration.ce_hours_requested).to eq(5) - expect(existing_registration.ce_license_number).to eq("LIC-987") + ce_registration = existing_registration.reload.continuing_education_registrations.first + expect(ce_registration.hours).to eq(5) + expect(ce_registration.professional_license.number).to eq("LIC-987") end it "sets the shout-out flag and stores the shout-out text on the registrant" do diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 091d9a2029..37126a731b 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -77,7 +77,7 @@ def checked?(body, registration) it "returns to the picker after save" do patch event_registration_path(jane), - params: { return_to: "preview_reminder", event_registration: { ce_credit_requested: "1" } } + params: { return_to: "preview_reminder", event_registration: { intends_to_pay: "1" } } expect(response).to redirect_to(preview_reminder_event_path(event)) end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index abab55caf9..96192f565a 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -222,19 +222,36 @@ let!(:registration) { create(:event_registration, event: event, registrant: user.person) } it "shows status, cost, and the license number on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: "LIC123") + event.update!(ce_hours: 6) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) expect(response.body).to include("Requested") - expect(response.body).to include("Hours requested") + expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") end it "notes when the license number is not yet on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: nil) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) - expect(response.body).to include("We don't have your license number on file yet.") + expect(response.body).to include("Not on file yet.") + end + end + + describe "POST /registration/:slug/ce/license" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "saves a license number entered on the callout" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + post registration_ce_license_path(registration.slug), params: { license_number: "LMFT 7788" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(registration.continuing_education_registrations.first.professional_license.number).to eq("LMFT 7788") end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 2104a8c152..3d4dedfe42 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -158,15 +158,15 @@ describe "GET /ce_hours" do let(:event) { create(:event, :published, :publicly_visible) } - it "renders the CE hours page when details are present" do - event.update!(ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") + it "renders the CE hours page when the event is CE-eligible" do + event.update!(ce_hours_eligible: true, ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") get ce_hours_event_path(event) expect(response).to have_http_status(:ok) expect(response.body).to include("Continuing education") expect(response.body).to include("Email your license number") end - it "redirects to the event when details are blank" do + it "redirects to the event when the event is not CE-eligible" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index abe26dd270..01732ea15b 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -452,67 +452,67 @@ def register_with_org(extra) field end - def register_with_ce(answer) + let!(:ce_license_field) do + form.form_fields.create!( + name: "License number", + answer_type: :free_form_input_one_line, + status: :active, + position: (form.form_fields.maximum(:position) || 0) + 1, + required: false, + field_identifier: described_class::CE_LICENSE_NUMBER_IDENTIFIER, + section: "continuing_education", + visibility: :always_ask + ) + end + + def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") params = params.merge(ce_field.id.to_s => answer) unless answer.nil? + params = params.merge(ce_license_field.id.to_s => license) if license described_class.call(event: event, form: form, form_params: params) end - it "toggles ce_credit_requested on when answered Yes" do + it "creates a CE registration when answered Yes" do result = register_with_ce("Yes") - expect(result.event_registration.ce_credit_requested).to be true + expect(result.event_registration.continuing_education_registrations.count).to eq(1) end - it "leaves ce_credit_requested off when answered No" do + it "creates no CE registration when answered No" do result = register_with_ce("No") - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "leaves ce_credit_requested off when unanswered" do + it "creates no CE registration when unanswered" do result = register_with_ce(nil) - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "toggles ce_credit_requested on for an existing registration that answers Yes" do + it "creates a CE registration for an existing registration that answers Yes" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + existing = create(:event_registration, event: event, registrant: person) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_credit_requested).to be true + expect(existing.reload.continuing_education_registrations.count).to eq(1) end - it "saves the hours folded into a 'Yes: ' specify answer" do - result = register_with_ce("Yes: 6") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to eq(6) + it "records the typed license number on the CE registration's license" do + result = register_with_ce("Yes", license: "LMFT 555") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.number).to eq("LMFT 555") + expect(license.person).to eq(result.event_registration.registrant) end - it "leaves ce_hours_requested nil when Yes carries no hours" do + it "uses a placeholder license when no number is given" do result = register_with_ce("Yes") - expect(result.event_registration.ce_hours_requested).to be_nil + expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil end - it "leaves ce_hours_requested nil when answered No" do - result = register_with_ce("No") - expect(result.event_registration.ce_hours_requested).to be_nil - end - - it "ignores non-numeric hours in the specify answer" do - result = register_with_ce("Yes: lots") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to be_nil - end - - it "saves the hours onto an existing registration that answers Yes" do - person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) - - register_with_ce("Yes: 4") - - expect(existing.reload.ce_credit_requested).to be true - expect(existing.ce_hours_requested).to eq(4) + it "takes the CE hours from the event" do + event.update!(ce_hours: 6) + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) end end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index b7f3b77578..da34c39e7f 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -68,31 +68,25 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do - event.update!(ce_hours_details: "6 hours") expect(card_titles(registration)).not_to include(event.ce_hours_details_label) - registration.update!(ce_credit_requested: true) - expect(card_titles(registration)).to include(event.ce_hours_details_label) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) end - it "shows an amber 'what's needed' CE badge until complete, then a teal amount due" do - registration.update!(ce_credit_requested: true, ce_hours_requested: nil, ce_license_number: nil) - both = card(registration, event.ce_hours_details_label) - expect(both.theme).to eq(DomainTheme.swatch("teal")) - expect(both.subtitle).to eq("Continuing education credit") - expect(both.badge).to eq("Hours & license number needed") - expect(both.badge_classes).to be_nil - - registration.update!(ce_hours_requested: 6, ce_license_number: nil) - license = card(registration, event.ce_hours_details_label) - expect(license.subtitle).to eq("6 hours") - expect(license.badge).to eq("$150 · License number needed") - expect(license.badge_classes).to be_nil - - registration.update!(ce_hours_requested: nil, ce_license_number: "LIC123") - expect(card(registration, event.ce_hours_details_label).badge).to eq("Hours needed") - - registration.update!(ce_hours_requested: 6, ce_license_number: "LIC123") - complete = card(registration, event.ce_hours_details_label) + it "shows a 'license needed' CE badge until provided, then a teal amount due" do + event.update!(ce_hours: 6) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + + needs = card(registration.reload, event.ce_hours_details_label) + expect(needs.theme).to eq(DomainTheme.swatch("teal")) + expect(needs.subtitle).to eq("6 hours") + expect(needs.badge).to eq("$150 · License number needed") + expect(needs.badge_classes).to be_nil + + license.update!(number: "LIC123") + complete = card(registration.reload, event.ce_hours_details_label) expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -125,10 +119,12 @@ def card(reg, title) end it "places payment first and FAQ last in the full ordering" do - event.update!(event_details: "Bring supplies", ce_hours_details: "6 hours", + event.update!(event_details: "Bring supplies", ce_hours_details: "6 hours", ce_hours: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) - registration.update!(status: "attended", scholarship_requested: true, ce_credit_requested: true) + registration.update!(status: "attended", scholarship_requested: true) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration)).to eq([ "Make your payment", "Certificate of completion", diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index f4e6c11a39..ec5e53c77f 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -170,17 +170,24 @@ def matched(params, registrations) end context "CE status" do - # Requested CE, supplied both license and hours, and paid in full. + # Requested CE, supplied a license, and paid the CE balance in full. let!(:complete) do registration(first_name: "Complete").tap do |r| - r.update!(ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, allocatable: r, amount: 10_000) + license = create(:professional_license, person: r.registrant, number: "ABC123") + ce_reg = create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + payment = create(:payment, amount_cents: ce_reg.amount_cents, amount_cents_remaining: ce_reg.amount_cents) + create(:allocation, source: payment, allocatable: ce_reg, amount: ce_reg.amount_cents) + end + end + # Requested CE but on a placeholder license, unpaid. + let!(:missing) do + registration(first_name: "Missing").tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) end end - # Requested CE but missing license and hours, unpaid. - let!(:missing) { registration(first_name: "Missing").tap { |r| r.update!(ce_credit_requested: true) } } # Did not request CE at all. - let!(:no_ce) { registration(first_name: "None").tap { |r| r.update!(ce_license_number: nil, ce_hours_requested: nil) } } + let!(:no_ce) { registration(first_name: "None") } let(:regs) { [ complete, missing, no_ce ] } it "filters CE requested" do @@ -191,11 +198,7 @@ def matched(params, registrations) expect(matched({ ce_status: "license_not_provided" }, regs)).to eq([ missing.id ].to_set) end - it "filters CE hours not provided (only among CE requesters)" do - expect(matched({ ce_status: "hours_not_provided" }, regs)).to eq([ missing.id ].to_set) - end - - it "filters CE paid (requested CE and paid in full)" do + it "filters CE paid (requested CE and CE paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index f44722ef95..74102fcb1a 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -73,8 +73,9 @@ email_2: "robin.alt@example.com") registration = event.event_registrations.find_by!(registrant: person) - expect(registration).to have_attributes(scholarship_requested: false, ce_credit_requested: true, + expect(registration).to have_attributes(scholarship_requested: false, w9_requested: true, invoice_requested: false) + expect(registration.continuing_education_registrations.count).to eq(1) answers = answers_by_identifier(registration_form.form_submissions.find_by!(person: person)) expect(answers).to include(