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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/models/professional_license.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions app/services/form_builder_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions app/services/magic_ticket_callouts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions app/views/event_mailer/_ce_deadlines.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

πŸ€– From Claude: Gated on event.ce_eligible?, not registration.ce_registered? β€” the confirmation email fires at registration time, before anyone opts into CE, so registrant-level gating would hide the deadline from exactly the people it should nudge. Event-level shows it to everyone at a CE-eligible event.

<div style="text-align: left; background-color: #f0fdfa; border: 1px solid #99f6e4; border-radius: 6px; padding: 16px; margin: 16px 0;">
<p style="font-size: 14px; font-weight: 600; color: #115e59; margin: 0 0 8px;">
<%= event.ce_hours_details_label %>
</p>
<% if event.ce_hours_request_deadline %>
<p style="font-size: 14px; color: #374151; margin: 0 0 4px;">
Request CE credit by <strong><%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %></strong>
</p>
<% end %>
<% if event.ce_payment_due_deadline %>
<p style="font-size: 14px; color: #374151; margin: 0;">
CE payment due by <strong><%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %></strong>
</p>
<% end %>
</div>
<% end %>
5 changes: 5 additions & 0 deletions app/views/event_mailer/_ce_deadlines.text.erb
Original file line number Diff line number Diff line change
@@ -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 %>
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
<% end %>
</div>

<%= render "ce_deadlines", event: @event %>

<% if @event.rhino_description.present? %>
<span style="padding-bottom: 12px"><strong>Details</strong></span><br>
<p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/views/event_mailer/event_registration_reminder.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<% end %>

<%= render "event_details_card", event: @event, time_zone: @time_zone %>

<%= render "ce_deadlines", event: @event %>
</div>

<% if @event_registration.persisted? && @event_registration.slug.present? %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) %>
Expand Down
35 changes: 35 additions & 0 deletions app/views/events/_ce_config_fields.html.erb
Original file line number Diff line number Diff line change
@@ -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). %>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<div>
<%= 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" %>
</div>
<div>
<%= f.label :ce_hours_cost, "Total CE cost", class: "block text-xs font-medium text-gray-600 mb-1" %>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<%= 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" %>
</div>
</div>
<div>
<%= f.label :ce_hours_request_deadline, class: "block text-xs font-medium text-gray-600 mb-1" do %>
Request CE credit by <span class="font-normal text-gray-400">(optional)</span>
<% 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" %>
</div>
<div>
<%= f.label :ce_payment_due_deadline, class: "block text-xs font-medium text-gray-600 mb-1" do %>
Payment due by <span class="font-normal text-gray-400">(optional)</span>
<% 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" %>
</div>
</div>
21 changes: 6 additions & 15 deletions app/views/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
<span class="text-sm font-medium text-gray-700">Enable continuing education</span>
<label for="enable_continuing_education" class="text-sm font-medium text-gray-700 cursor-pointer">Enable continuing education</label>
<p class="text-xs text-gray-500 mt-1">Allow registrants to request CE hours.</p>
<div class="hidden peer-checked:flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
<label class="flex items-center gap-2 text-sm text-gray-700">CE hours offered <%= f.number_field :ce_hours_offered, min: 0, step: 0.25, class: "w-24 rounded-md border-gray-300 text-sm tabular-nums focus:border-teal-500 focus:ring-teal-200" %></label>

<label class="flex items-center gap-2 text-sm text-gray-700">
Total CE cost <span class="text-gray-400">$</span> <%= f.number_field :ce_hours_cost, min: 0, step: 1,
class: "w-24 rounded-md border-gray-300 text-sm tabular-nums focus:border-teal-500 focus:ring-teal-200" %>
</label>
<div class="hidden peer-checked:block rounded-md border border-teal-200 bg-teal-50 px-3 py-2.5">
<%= render "events/ce_config_fields", f: f %>
</div>
<p class="mt-1 text-xs text-gray-400">Edit the CE hours ticket card and its details below, under Registration ticket callouts.</p>
<% if @event.continuing_education_form.present? %>
Expand All @@ -495,13 +491,8 @@
<% end %>
</select>
<p class="text-xs text-gray-500 mt-1">Select a continuing education form for registrants to request CE hours.</p>
<div class="hidden peer-checked:flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
<label class="flex items-center gap-2 text-sm text-gray-700">CE hours offered <%= f.number_field :ce_hours_offered, min: 0, step: 0.25, class: "w-24 rounded-md border-gray-300 text-sm tabular-nums focus:border-teal-500 focus:ring-teal-200" %></label>

<label class="flex items-center gap-2 text-sm text-gray-700">
Total CE cost <span class="text-gray-400">$</span> <%= f.number_field :ce_hours_cost, min: 0, step: 1,
class: "w-24 rounded-md border-gray-300 text-sm tabular-nums focus:border-teal-500 focus:ring-teal-200" %>
</label>
<div class="mt-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-2.5">
<%= render "events/ce_config_fields", f: f %>
</div>
<% end %>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
data: { callout_preview_target: "colorSelect", action: "callout-preview#update" } %>

<% if f.object.app_colored? %>
<p class="text-xs text-gray-400 mt-0.5">The app overrides this with a live-status color when the registrant has action to take (e.g. amber while something's outstanding, orange while a balance is due).</p>
<p class="text-xs text-gray-400 mt-0.5">This callout will be a different color when the registrant has action to take (e.g. amber if something's outstanding, orange if a balance is due).</p>
<% end %>
</div>

Expand Down
6 changes: 6 additions & 0 deletions app/views/events/callouts/ce.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
</p>

<% if ce_registration && ce_registration.remaining_cost.positive? %>
<% if @event.ce_payment_due_deadline %>
<p class="mt-3 text-sm text-amber-700">Payment due by <%= @event.ce_payment_due_deadline.to_fs(:long) %></p>
<% end %>
<div class="mt-6 flex justify-center">
<%= button_to "Pay with Credit Card",
registration_ce_pay_path(@event_registration.slug),
Expand Down Expand Up @@ -224,6 +227,9 @@
<% end %>
<% else %>
<p class="text-sm text-gray-600">You haven't requested continuing education credit for this training. CE hours are available for an additional fee.</p>
<% if @event.ce_hours_request_deadline %>
<p class="mt-2 text-sm text-amber-700">Request CE credit by <%= @event.ce_hours_request_deadline.to_fs(:long) %>.</p>
<% 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 %>
Expand Down
Loading