diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index 59971b92e8..093e10580e 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -17,11 +17,12 @@ class ProfessionalLicense < ApplicationRecord # different kinds is allowed; only a duplicate (kind, number) pair is rejected. validates :number, uniqueness: { scope: [ :person_id, :kind ] }, allow_nil: true - # Find the person's license for this number, or create it. A blank number - # resolves to the person's single placeholder license (number nil) so a CE - # opt-in without a number on file never spawns duplicate placeholders. - def self.find_or_create_for(person:, number: nil) - find_or_create_by(person: person, number: number.presence) + # Find the person's license for this type + number, or create it. Licenses are + # identified by (kind, number), so an opt-in without either on file resolves to + # the person's single placeholder license (both nil) rather than spawning + # duplicate placeholders. + def self.find_or_create_for(person:, number: nil, kind: nil) + find_or_create_by(person: person, number: number.presence, kind: kind.presence) end # Completeness: have we recorded the actual license number yet? diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index f73ccbbbfa..4ee664e3d5 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -129,6 +129,8 @@ def google_analytics? :ce_hours_details_label, :ce_hours_offered, :ce_hours_cost, + :ce_hours_request_deadline, + :ce_payment_due_deadline, :autoshow_cost, :autoshow_date, :autoshow_location, diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index a644da14bc..b09e250e6e 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -11,6 +11,16 @@ class PublicRegistration # seeds the registrant's ProfessionalLicense. CE_LICENSE_NUMBER_IDENTIFIER = "ce_license_number".freeze + # Well-known field_identifier of the CE license-type question (e.g. "LCSW"). + # Its answer seeds the ProfessionalLicense's kind. + CE_LICENSE_KIND_IDENTIFIER = "ce_license_kind".freeze + + # Well-known field_identifiers of the CE license issuing-state and expiry + # questions. Their answers fill in the ProfessionalLicense's issuing_state + # and expires_on (both optional — registrants can supply them later). + CE_LICENSE_ISSUING_STATE_IDENTIFIER = "ce_license_issuing_state".freeze + CE_LICENSE_EXPIRES_ON_IDENTIFIER = "ce_license_expires_on".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. @@ -412,7 +422,10 @@ 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) + license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number, kind: ce_license_kind) + if ce_license_issuing_state || ce_license_expires_on + license.update!(issuing_state: ce_license_issuing_state, expires_on: ce_license_expires_on) + end event_registration.continuing_education_registrations.create!(professional_license: license) end @@ -428,6 +441,24 @@ def ce_license_number ce_field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence end + def ce_license_kind + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_KIND_IDENTIFIER)&.strip.presence + end + + def ce_license_issuing_state + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_ISSUING_STATE_IDENTIFIER)&.strip.presence + end + + def ce_license_expires_on + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_EXPIRES_ON_IDENTIFIER).presence + end + def ce_field_value(key) field = @continuing_education_form.form_fields.find_by(field_identifier: key) return nil unless field @@ -514,7 +545,6 @@ def save_scholarship_submission(person) def save_continuing_education_submission(person) return unless @continuing_education_form && @continuing_education_params.present? - return unless ce_credit_requested? submission = FormSubmission.find_or_create_by!( person: person, form: @continuing_education_form, role: "continuing_education", event: @event diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index dd5e727c10..ee342c7cbe 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -97,7 +97,10 @@ def call ], continuing_education: [ "Do you seek Continuing Education (CE) hours for this training?", - "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?" + "If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)", + "What is your license number?", + "In which state was your license issued?", + "When does your license expire?" ], scholarship: [ "I and/or my organization cannot afford the full training cost and need a scholarship to attend.", @@ -512,16 +515,31 @@ def build_continuing_education_fields(form, position) position = add_field(form, position, "Do you seek Continuing Education (CE) hours for this training?", :single_select_radio, - key: "ce_credit_interest", group: "continuing_education", required: false, + key: "ce_credit_interest", group: "continuing_education", required: true, options: %w[Yes No]) position = add_field(form, position, - "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", + "If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)", + :free_form_input_one_line, + key: "ce_license_kind", group: "continuing_education", required: false, + subtitle: "These license details are optional here — you can add or update them later " \ + "from your registration ticket.") + position = add_field(form, position, + "What is your license number?", :free_form_input_one_line, key: "ce_license_number", group: "continuing_education", required: false, subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ "and AWBW cannot guarantee your specific state board will accept them. " \ "Participants are responsible for confirming whether the hours meet the requirements " \ "for their specific license and state.") + position = add_field(form, position, + "In which state was your license issued?", + :free_form_input_one_line, + key: "ce_license_issuing_state", group: "continuing_education", required: false) + position = add_field(form, position, + "When does your license expire?", + :free_form_input_one_line, + key: "ce_license_expires_on", group: "continuing_education", required: false, + datatype: :date) position end diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index a0bb5f20b0..02c3779486 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -211,14 +211,18 @@ def ce_hours_subtitle # "$X due" for the outstanding balance once hours + license are on file (no chip # once paid in full); otherwise an amber chip naming what's still needed, prefixed # with the fee when the hours (and so the cost) are already known — e.g. - # "$250 · License number needed". + # "$250 · License number needed". Each deadline the event sets is appended to the + # relevant chip while still pending: the payment deadline on the amount-due chip + # (until paid), the request deadline on the license-needed chip. def ce_hours_badge(complete) ce_registration = registration.continuing_education_registrations.first remaining_cents = ce_registration&.remaining_cost.to_i if complete return unless remaining_cents.positive? - return "#{MoneyFormatter.dollars_from_cents(remaining_cents)} due" + amount = MoneyFormatter.dollars_from_cents(remaining_cents) + return "#{amount} due" if event.ce_payment_due_deadline.blank? + return "#{amount} due by #{ce_deadline_text(event.ce_payment_due_deadline)}" end needed = ce_missing_text @@ -227,9 +231,16 @@ def ce_hours_badge(complete) end # Hours are set by the event now, so the only thing a requesting registrant can - # still be missing is their license number. + # still be missing is their license number. When the event sets a request + # deadline, name it so the registrant knows when their license is due. def ce_missing_text - "License number needed" + return "License number needed" if event.ce_hours_request_deadline.blank? + "License number needed by #{ce_deadline_text(event.ce_hours_request_deadline)}" + end + + # Short month/day for a deadline shown inline on the CE card, e.g. "Jul 1". + def ce_deadline_text(deadline) + deadline.strftime("%b %-d") end # "Art supplies & what to bring" — the event's own details page. diff --git a/app/views/event_mailer/_ce_deadlines.html.erb b/app/views/event_mailer/_ce_deadlines.html.erb new file mode 100644 index 0000000000..015e52364b --- /dev/null +++ b/app/views/event_mailer/_ce_deadlines.html.erb @@ -0,0 +1,22 @@ +<%# CE deadlines for a CE-eligible event, shown in the confirmation and reminder + emails so registrants are nudged before the request/payment cutoffs — gated on + the event offering CE, not on this registrant having requested it yet (the + confirmation fires before anyone opts in). Locals: event (decorated). The + deadlines are plain dates, so no time zone is applied. %> +<% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %> +
+

+ <%= event.ce_hours_details_label %> +

+ <% if event.ce_hours_request_deadline %> +

+ Request CE credit by <%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %> +

+ <% end %> + <% if event.ce_payment_due_deadline %> +

+ CE payment due by <%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %> +

+ <% end %> +
+<% end %> diff --git a/app/views/event_mailer/_ce_deadlines.text.erb b/app/views/event_mailer/_ce_deadlines.text.erb new file mode 100644 index 0000000000..755ff4fc22 --- /dev/null +++ b/app/views/event_mailer/_ce_deadlines.text.erb @@ -0,0 +1,5 @@ +<% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %> +<%= event.ce_hours_details_label %> +<% if event.ce_hours_request_deadline %>Request CE credit by <%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %> +<% end %><% if event.ce_payment_due_deadline %>CE payment due by <%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %> +<% end %><% end %> \ No newline at end of file diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index e06eea9ee2..e1be1a0ea7 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -53,6 +53,8 @@ <% end %> + <%= render "ce_deadlines", event: @event %> + <% if @event.rhino_description.present? %> Details

diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index 1b0c5bc8cc..0cdf4aab69 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -27,6 +27,7 @@ Videoconference: <%= event_platform_label(@event) %> View your ticket: <%= registration_ticket_url(@event_registration.slug) %> +<%= render "ce_deadlines", event: @event %> <% if @event.registration_close_date %> Registration closes on diff --git a/app/views/event_mailer/event_registration_reminder.html.erb b/app/views/event_mailer/event_registration_reminder.html.erb index 121e44363e..42d78a0e70 100644 --- a/app/views/event_mailer/event_registration_reminder.html.erb +++ b/app/views/event_mailer/event_registration_reminder.html.erb @@ -18,6 +18,8 @@ <% end %> <%= render "event_details_card", event: @event, time_zone: @time_zone %> + + <%= render "ce_deadlines", event: @event %> <% if @event_registration.persisted? && @event_registration.slug.present? %> diff --git a/app/views/event_mailer/event_registration_reminder.text.erb b/app/views/event_mailer/event_registration_reminder.text.erb index 8aeb3b1302..913170528a 100644 --- a/app/views/event_mailer/event_registration_reminder.text.erb +++ b/app/views/event_mailer/event_registration_reminder.text.erb @@ -22,7 +22,7 @@ Location: <%= event_location_label(@event.object) %> <% if @event.labelled_cost.present? %> <%= @event.labelled_cost %> <% end %> - +<%= render "ce_deadlines", event: @event %> <% if @event_registration.slug.present? %> View your ticket for event details, directions, and calendar links: <%= registration_ticket_url(@event_registration.slug) %> diff --git a/app/views/events/_ce_config_fields.html.erb b/app/views/events/_ce_config_fields.html.erb new file mode 100644 index 0000000000..fae0139cfd --- /dev/null +++ b/app/views/events/_ce_config_fields.html.erb @@ -0,0 +1,35 @@ +<%# The CE settings an admin edits once continuing education is enabled: hours + offered, total cost, and the two optional deadlines. Rendered inside each + "Enable continuing education" box (single-form and multi-form variants). + Each field is labelled above its own white input so they read as distinct + fields; the date inputs match the event start/end date style. locals: f + (event form builder). %> +

+
+ <%= f.label :ce_hours_offered, "CE hours offered", class: "block text-xs font-medium text-gray-600 mb-1" %> + <%= f.number_field :ce_hours_offered, min: 0, step: 0.25, + class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 bg-white tabular-nums focus:ring-blue-500 focus:border-blue-500" %> +
+
+ <%= f.label :ce_hours_cost, "Total CE cost", class: "block text-xs font-medium text-gray-600 mb-1" %> +
+ $ + <%= f.number_field :ce_hours_cost, min: 0, step: 1, + class: "w-full rounded border-gray-300 shadow-sm pl-7 pr-3 py-2 bg-white tabular-nums focus:ring-blue-500 focus:border-blue-500" %> +
+
+
+ <%= f.label :ce_hours_request_deadline, class: "block text-xs font-medium text-gray-600 mb-1" do %> + Request CE credit by (optional) + <% end %> + <%= f.text_field :ce_hours_request_deadline, type: "date", + class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 bg-white focus:ring-blue-500 focus:border-blue-500" %> +
+
+ <%= f.label :ce_payment_due_deadline, class: "block text-xs font-medium text-gray-600 mb-1" do %> + Payment due by (optional) + <% end %> + <%= f.text_field :ce_payment_due_deadline, type: "date", + class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 bg-white focus:ring-blue-500 focus:border-blue-500" %> +
+
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index eee2108d30..2a2fa229ef 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -460,16 +460,12 @@ <% single_form = @continuing_education_forms.first %> <%= check_box_tag "event[continuing_education_form_id]", single_form.id, resubmitted ? params.dig(:event, :continuing_education_form_id).present? : @event.continuing_education_form.present?, + id: "enable_continuing_education", class: "peer rounded border-gray-300" %> - Enable continuing education +

Allow registrants to request CE hours.

- diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 3e82cd17b2..4237e55015 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -82,6 +82,9 @@

<% if ce_registration && ce_registration.remaining_cost.positive? %> + <% if @event.ce_payment_due_deadline %> +

Payment due by <%= @event.ce_payment_due_deadline.to_fs(:long) %>

+ <% end %>
<%= button_to "Pay with Credit Card", registration_ce_pay_path(@event_registration.slug), @@ -224,6 +227,9 @@ <% end %> <% else %>

You haven't requested continuing education credit for this training. CE hours are available for an additional fee.

+ <% if @event.ce_hours_request_deadline %> +

Request CE credit by <%= @event.ce_hours_request_deadline.to_fs(:long) %>.

+ <% end %> <%= form_with url: registration_ce_request_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-4" do |form| %> <%= form.submit "Request CE credit", class: "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" %> <% end %> diff --git a/app/views/events/ce_hours.html.erb b/app/views/events/ce_hours.html.erb index 35a8af9d20..8dc651515b 100644 --- a/app/views/events/ce_hours.html.erb +++ b/app/views/events/ce_hours.html.erb @@ -25,6 +25,22 @@
+ <% if @event.ce_hours_request_deadline || @event.ce_payment_due_deadline %> +
+ <% if @event.ce_hours_request_deadline %> +
+
Request CE credit by
+
<%= @event.ce_hours_request_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> + <% if @event.ce_payment_due_deadline %> +
+
Payment due by
+
<%= @event.ce_payment_due_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> +
+ <% end %> <%= render "events/callouts/rich_content", content: @event.ce_hours_details %>
diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 9605cfe1ca..e70fb2295c 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -45,11 +45,15 @@ <% else %> <% - html_type = case field.field_identifier - when /email(?!_type)/ then "email" - when "phone" then "tel" + html_type = if field.date? + "date" else - field.number_integer? ? "number" : "text" + case field.field_identifier + when /email(?!_type)/ then "email" + when "phone" then "tel" + else + field.number_integer? ? "number" : "text" + end end extra_attrs = [] extra_attrs << 'min="1" step="1" pattern="[0-9]*" inputmode="numeric"' if field.number_integer? diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index d16835d352..719e2f3ed9 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -162,6 +162,22 @@ <%= @continuing_education_form.display_name %> <%= render "forms/header", form: @continuing_education_form, event: @event %> + <% if @event.ce_hours_request_deadline || @event.ce_payment_due_deadline %> +
+ <% if @event.ce_hours_request_deadline %> +
+
Request CE credit by
+
<%= @event.ce_hours_request_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> + <% if @event.ce_payment_due_deadline %> +
+
Payment due by
+
<%= @event.ce_payment_due_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> +
+ <% end %>
<% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %> <% if field.group_header? %> diff --git a/db/migrate/20260622135205_add_ce_deadlines_to_events.rb b/db/migrate/20260622135205_add_ce_deadlines_to_events.rb new file mode 100644 index 0000000000..f282483908 --- /dev/null +++ b/db/migrate/20260622135205_add_ce_deadlines_to_events.rb @@ -0,0 +1,11 @@ +class AddCeDeadlinesToEvents < ActiveRecord::Migration[8.1] + def up + add_column :events, :ce_hours_request_deadline, :date unless column_exists?(:events, :ce_hours_request_deadline) + add_column :events, :ce_payment_due_deadline, :date unless column_exists?(:events, :ce_payment_due_deadline) + end + + def down + remove_column :events, :ce_hours_request_deadline, if_exists: true + remove_column :events, :ce_payment_due_deadline, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index a8e904901b..367f5f508d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -537,6 +537,8 @@ t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false t.decimal "ce_hours_offered", precision: 5, scale: 2 + t.date "ce_hours_request_deadline" + t.date "ce_payment_due_deadline" t.integer "cost_cents" t.datetime "created_at", null: false t.integer "created_by_id" diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index fa9c2a4c2f..81c717f191 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -408,7 +408,7 @@
  • Complete the post-training evaluation within one week.
  • Your CE certificate will be emailed within three weeks of the training.
  • -

    Questions about continuing education? Reach out and our team will follow up with details.

    +

    Questions about continuing education? Reach out by email or Portal contact us form, and our team will follow up with details.

    HTML end diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index 4f7bbad5ee..d9d4bfdd76 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -90,6 +90,31 @@ expect(body).not_to include("Meeting ID") end end + + context "when the event is CE-eligible and has deadlines" do + let(:event) do + create(:event, ce_hours_offered: 6, + ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + end + let(:event_registration) { create(:event_registration, event: event) } + + it "surfaces both CE deadlines in the body" do + expect(mail.body.encoded).to include("Request CE credit by") + expect(mail.body.encoded).to include("July 1, 2026") + expect(mail.body.encoded).to include("CE payment due by") + expect(mail.body.encoded).to include("August 15, 2026") + end + end + + context "when the event is not CE-eligible" do + let(:event) { create(:event, ce_hours_offered: nil, ce_hours_request_deadline: Date.new(2026, 7, 1)) } + let(:event_registration) { create(:event_registration, event: event) } + + it "omits the CE deadlines" do + expect(mail.body.encoded).not_to include("Request CE credit by") + end + end end describe "#bulk_payment_confirmation" do @@ -156,6 +181,22 @@ expect(mail.body.encoded).to include(event_registration.registrant.full_name) end + context "when the event is CE-eligible and has deadlines" do + let(:event) do + create(:event, ce_hours_offered: 6, + ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + end + let(:event_registration) { create(:event_registration, event: event) } + + it "surfaces both CE deadlines in the body" do + expect(mail.body.encoded).to include("Request CE credit by") + expect(mail.body.encoded).to include("July 1, 2026") + expect(mail.body.encoded).to include("CE payment due by") + expect(mail.body.encoded).to include("August 15, 2026") + end + end + context "with a custom subject" do let(:mail) { described_class.event_registration_reminder(event_registration, custom_subject: "Don't forget us tomorrow!") } diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index ad43d763b6..cf979e7873 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -27,6 +27,13 @@ expect(first.number).to be_nil expect(person.professional_licenses.count).to eq(1) end + + it "records the license type when given" do + license = described_class.find_or_create_for(person: person, number: "ABC-1", kind: "LCSW") + + expect(license.kind).to eq("LCSW") + expect(license.number).to eq("ABC-1") + end end describe "#number_known?" do diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index b07b0aca37..85514692c1 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -250,6 +250,32 @@ def post_with_scholarship(scholarship_answer) expect(response.body).to include("Minimum of 5 words.") end + it "surfaces the CE deadlines on the continuing education section" do + ce = FormBuilderService.new(name: "CE", sections: %i[continuing_education], role: "continuing_education").call + event.event_forms.create!(form: ce, role: "continuing_education") + event.update!(ce_hours_request_deadline: Date.new(2026, 7, 1), ce_payment_due_deadline: Date.new(2026, 8, 15)) + + get new_event_public_registration_path(event) + + expect(response.body).to include("Request CE credit by") + expect(response.body).to include("July 1, 2026") + expect(response.body).to include("Payment due by") + expect(response.body).to include("August 15, 2026") + end + + it "renders the CE license type, issuing-state dropdown, expiry date, and provide-later note" do + ce = FormBuilderService.new(name: "CE", sections: %i[continuing_education], role: "continuing_education").call + event.event_forms.create!(form: ce, role: "continuing_education") + + get new_event_public_registration_path(event) + + expect(response.body).to include("what is your license type?") + expect(response.body).to include("In which state was your license issued?") + expect(response.body).to include("Select a state") # issuing state renders as the US-states dropdown + expect(response.body).to include('type="date"') # expiry renders as a date input + expect(response.body).to include("add or update them later") + end + it "renders a structured details panel from known event data when enabled" do pacific = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] event.update!( diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index efb5c1eaa9..7c6a2e6b5d 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -226,6 +226,17 @@ def offer_ce!(target_event) get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end + + it "surfaces the CE deadlines when set" do + event.update!(ce_hours_offered: 6, ce_hours_details: "

    CE info

    ", + ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + get ce_hours_event_path(event) + expect(response.body).to include("Request CE credit by") + expect(response.body).to include("July 1, 2026") + expect(response.body).to include("Payment due by") + expect(response.body).to include("August 15, 2026") + end end describe "GET /new" do @@ -287,6 +298,14 @@ def offer_ce!(target_event) get edit_event_path(event) expect(response.body).to include("Frequently asked questions") end + + it "renders the CE deadline fields in the continuing education settings" do + create(:form, :standalone, role: "continuing_education", name: "CE") + get edit_event_path(event) + expect(response.body).to include('name="event[ce_hours_request_deadline]"') + expect(response.body).to include('name="event[ce_payment_due_deadline]"') + expect(response.body).to include("Request CE credit by") + end end describe "POST /create" do @@ -446,6 +465,15 @@ def offer_ce!(target_event) expect(event.reload.hint_dates).to eq("must attend both days") expect(event.hint_registration_cost).to eq("due within 3 weeks of registration") end + + it "persists the CE deadlines" do + patch event_path(event), params: { event: { + ce_hours_request_deadline: "2026-07-01", + ce_payment_due_deadline: "2026-08-15" + } } + expect(event.reload.ce_hours_request_deadline).to eq(Date.new(2026, 7, 1)) + expect(event.ce_payment_due_deadline).to eq(Date.new(2026, 8, 15)) + end end context "as non-admin" do diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 4a75db2ab3..24bec6c872 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -542,15 +542,23 @@ def ce_field_id(key) ce_form.form_fields.find_by!(field_identifier: key).id.to_s end - def register_with_ce(answer, license: nil) + def register_with_ce(answer, license: nil, kind: nil, issuing_state: nil, expires_on: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") ce_params = {} ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil? ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license + ce_params[ce_field_id(described_class::CE_LICENSE_KIND_IDENTIFIER)] = kind if kind + ce_params[ce_field_id(described_class::CE_LICENSE_ISSUING_STATE_IDENTIFIER)] = issuing_state if issuing_state + ce_params[ce_field_id(described_class::CE_LICENSE_EXPIRES_ON_IDENTIFIER)] = expires_on if expires_on described_class.call(event: event, registration_form: form, form_params: params, continuing_education_form: ce_form, continuing_education_params: ce_params) end + it "marks the CE interest question as required" do + field = ce_form.form_fields.find_by!(field_identifier: described_class::CE_CREDIT_INTEREST_IDENTIFIER) + expect(field.required).to be(true) + end + it "creates a CE registration when answered Yes" do result = register_with_ce("Yes") expect(result.event_registration.continuing_education_registrations.count).to eq(1) @@ -595,6 +603,20 @@ def register_with_ce(answer, license: nil) expect(license.person).to eq(result.event_registration.registrant) end + it "records the typed license type alongside the number" do + result = register_with_ce("Yes", license: "555", kind: "LCSW") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.kind).to eq("LCSW") + expect(license.number).to eq("555") + end + + it "records the license issuing state and expiry when given" do + result = register_with_ce("Yes", license: "555", issuing_state: "CA", expires_on: "2027-05-31") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.issuing_state).to eq("CA") + expect(license.expires_on).to eq(Date.new(2027, 5, 31)) + end + it "uses a placeholder license when no number is given" do result = register_with_ce("Yes") expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil @@ -615,9 +637,11 @@ def register_with_ce(answer, license: nil) expect(answers[described_class::CE_LICENSE_NUMBER_IDENTIFIER]).to eq("LMFT 555") end - it "does not persist a continuing_education submission when answered No" do + it "persists the CE interest answer even when answered No" do register_with_ce("No") - expect(FormSubmission.find_by(role: "continuing_education", event: event)).to be_nil + submission = FormSubmission.find_by(role: "continuing_education", event: event) + expect(submission).to be_present + expect(submission.answers_by_identifier[described_class::CE_CREDIT_INTEREST_IDENTIFIER]).to eq("No") end end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 793ea27089..ee24ef2ce5 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -107,6 +107,23 @@ def card(reg, title) expect(paid.badge).to be_nil end + it "names the CE request deadline on the license-needed badge" do + event.update!(ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 · License number needed by Jul 1") + end + + it "appends the CE payment deadline to the amount-due badge, dropping it once paid" do + event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + ce_reg = create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15") + + create(:allocation, source: create(:payment), allocatable: ce_reg, amount: ce_reg.cost_cents) + expect(card(registration.reload, event.ce_hours_details_label).badge).to be_nil + end + it "shows the scholarship card only when requested, without an amount chip until awarded" do expect(card_titles(registration)).not_to include("Scholarship") registration.update!(scholarship_requested: true) @@ -204,5 +221,16 @@ def card(reg, title) # Certificate isn't unlocked (event not ended, not attended). expect(described_class.new(registration).card_for(callout)).to be_nil end + + it "keeps the live CE deadline badge on a materialized CE row" do + event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + callout = create(:registration_ticket_callout, event:, magic_key: "ce_hours", title: "CE credit") + + card = described_class.new(registration.reload).card_for(callout) + expect(card.title).to eq("CE credit") # row owns the text + expect(card.badge).to eq("$150 due by Aug 15") # app keeps the live deadline badge + end end end diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index 85b66cb545..381f0e2be0 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -144,6 +144,7 @@ choose_pr_radio reg_field("racial_ethnic_identity"), "Asian" choose_pr_radio reg_field("referral_source"), "Social Media" choose_pr_radio reg_field("payment_method"), "Check" + choose_pr_radio ce_field("ce_credit_interest"), "No" check_pr_box reg_field("communication_consent"), "Yes" expect { click_button "Register" }.not_to change(Person, :count) @@ -174,6 +175,7 @@ fill_full_registration choose_pr_radio reg_field("payment_method"), "Check" + choose_pr_radio ce_field("ce_credit_interest"), "No" choose_pr_radio schol_field("scholarship_eligibility"), "Yes" fill_pr_text schol_field("scholarship_contribution"), with: "Our agency can pay $200." @@ -205,6 +207,7 @@ visit new_event_public_registration_path(event, scholarship_requested: true) choose_pr_radio reg_field("payment_method"), "Check" + choose_pr_radio ce_field("ce_credit_interest"), "No" check_pr_box reg_field("communication_consent"), "Yes" choose_pr_radio schol_field("scholarship_eligibility"), "Yes" diff --git a/test/mailers/previews/event_mailer_preview.rb b/test/mailers/previews/event_mailer_preview.rb index ebcda85e09..065b3f0725 100644 --- a/test/mailers/previews/event_mailer_preview.rb +++ b/test/mailers/previews/event_mailer_preview.rb @@ -47,7 +47,14 @@ def sample_event_registration event = Event.first || create_event person = Person.first || create_person - EventRegistration.find_or_create_by!(event: event, registrant: person) + registration = EventRegistration.find_or_create_by!(event: event, registrant: person) + # Showcase the CE deadlines block (set in memory only, not persisted). The email + # gates on the event being CE-eligible, so give it offered hours + deadlines. Set + # on registration.event — the object the mailer decorates and reads. + registration.event.ce_hours_offered ||= 6 + registration.event.ce_hours_request_deadline ||= 2.weeks.from_now.to_date + registration.event.ce_payment_due_deadline ||= 3.weeks.from_now.to_date + registration end def create_event