diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 217463f976..994f3bd659 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -261,7 +261,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti - Include screenshots for UI changes - **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description - **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts -- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff +- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments - **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From :_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content - **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead diff --git a/AGENTS.md b/AGENTS.md index ab63e0cd64..fcd21ef95f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,19 +48,19 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~78 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files | -| `app/jobs/` | SolidQueue background jobs | 3 files | +| `app/models/` | ActiveRecord models | ~80 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files | +| `app/jobs/` | SolidQueue background jobs | 4 files | | `app/models/concerns/` | Shared model modules | 16 concerns | ### Presentation | Directory | Purpose | Count | |---|---|---| -| `app/controllers/` | Rails controllers (admin/, events/) | ~70 files | -| `app/views/` | ERB templates | ~504 files | -| `app/decorators/` | Draper decorators for view logic | ~38 files | -| `app/policies/` | ActionPolicy authorization rules | ~49 files | +| `app/controllers/` | Rails controllers (admin/, events/) | ~77 files | +| `app/views/` | ERB templates | ~632 files | +| `app/decorators/` | Draper decorators for view logic | ~40 files | +| `app/policies/` | ActionPolicy authorization rules | ~55 files | | `app/presenters/` | Presentation objects | 3 files | | `app/helpers/` | View helpers | ~24 files | | `app/mailers/` | ActionMailer classes | 5 files | @@ -81,7 +81,7 @@ This codebase (Rails 8.1) |---|---| | `config/routes.rb` | All routes (single file) | | `config/database.yml` | MySQL via Trilogy adapter | -| `config/initializers/` | ~28 initializer files | +| `config/initializers/` | ~30 initializer files | | `.github/workflows/` | GitHub Actions CI | | `Procfile.dev` | Dev services: `vite` + `web` | | `ai/` | Shell script shortcuts for common dev tasks (see `ai/README.md`) | @@ -147,7 +147,7 @@ This codebase (Rails 8.1) ### Namespaces -- **Root level** (~52 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc. +- **Root level** (~58 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc. - **`admin/`**: HomeController, AnalyticsController, AhoyActivitiesController - **`events/`**: Registrations sub-resource (create/destroy + slug-based show at `/registration/:slug`) - **Devise overrides**: Registrations, Confirmations, Passwords @@ -276,6 +276,7 @@ end - `asset_picker` — Asset selection UI - `autosave` — Auto-save form state - `carousel` — Swiper-based carousels +- `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one) - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility @@ -340,17 +341,17 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`: | Directory | Count | Purpose | |---|---|---| -| `spec/models/` | ~58 | Model unit tests | -| `spec/views/` | ~73 | View template tests | -| `spec/requests/` | ~47 | HTTP request/integration tests | -| `spec/system/` | ~25 | End-to-end browser tests (Capybara) | -| `spec/routing/` | ~13 | Route definition tests | -| `spec/policies/` | ~9 | Authorization policy tests | -| `spec/decorators/` | ~10 | Decorator tests | -| `spec/services/` | ~12 | Service object tests | +| `spec/models/` | ~71 | Model unit tests | +| `spec/views/` | ~76 | View template tests | +| `spec/requests/` | ~79 | HTTP request/integration tests | +| `spec/system/` | ~20 | End-to-end browser tests (Capybara) | +| `spec/routing/` | ~15 | Route definition tests | +| `spec/policies/` | ~14 | Authorization policy tests | +| `spec/decorators/` | ~14 | Decorator tests | +| `spec/services/` | ~22 | Service object tests | | `spec/mailers/` | ~5 | Mailer tests | -| `spec/helpers/` | ~1 | Helper tests | -| `spec/factories/` | ~53 | FactoryBot factory definitions | +| `spec/helpers/` | ~5 | Helper tests | +| `spec/factories/` | ~67 | FactoryBot factory definitions | ### Configuration @@ -423,8 +424,12 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (4 files): +Located in `lib/tasks/` (8 files): - `dev.rake` — Development database seeding from XML/CSV - `rhino_migrator.rake` — Rich text editor migration - `attachment_report.rake` — Attachment reporting - `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration +- `convert_age_ranges.rake` — Age range data conversion +- `legacy_user_permissions_to_comments.rake` — Migrate legacy user permissions into comments +- `migrate_sectors.rake` — Sector data migration +- `migrate_workshop_logs.rake` — Workshop log migration diff --git a/CLAUDE.md b/CLAUDE.md index 40356d2cb1..4fdd042e04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti - Include screenshots for UI changes - **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description - **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts -- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff +- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments - **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From :_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content - **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead diff --git a/Gemfile.lock b/Gemfile.lock index cf886418fd..954cf83f7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -168,8 +168,9 @@ GEM country_select (11.0.0) countries (> 6.0, < 9.0) crass (1.0.7) - css_parser (2.2.0) + css_parser (3.0.0) addressable + ssrf_filter (~> 1.5) csv (3.3.5) date (3.5.1) debug (1.11.1) @@ -311,7 +312,7 @@ GEM minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - msgpack (1.8.0) + msgpack (1.8.3) multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) mutex_m (0.3.0) @@ -713,6 +714,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + ssrf_filter (1.5.0) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -884,7 +886,7 @@ CHECKSUMS countries (8.1.0) sha256=4d6b318b8e906f1f769d5c021c13a418d33e917dc96ceb625a91d8e7ab2d192e country_select (11.0.0) sha256=0ab0a385fa70eadc71473af4b21b9b4d09f75660cc1c282a5bf6ec873719204b crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 - css_parser (2.2.0) sha256=23d1b247d7bc78cb2f2fe54629fb755e68d3004f1d1bd5c66d5096d42bff6325 + css_parser (3.0.0) sha256=eaf0e9283fd581d06e815235ceef4f0910c0b394c606355dbc69f93e84443885 csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 @@ -952,7 +954,7 @@ CHECKSUMS mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d @@ -1094,6 +1096,7 @@ CHECKSUMS solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1 sprockets-rails (3.2.2) sha256=62862bce136e31d7497eededde5f7730d4096bc8ef33ef7037c41423ccf89557 + ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 stripe (18.4.2) sha256=fd08a73ab87fc0b0ad938ca71293e1051e593001b70de5e9fcf12d1097133831 diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb new file mode 100644 index 0000000000..43e8b49496 --- /dev/null +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -0,0 +1,117 @@ +class ContinuingEducationRegistrationsController < ApplicationController + before_action :set_ce_registration, except: [ :new, :create ] + before_action :set_event_registration, only: [ :new, :create ] + + # Deliberate "Add CE registration" path, mirroring scholarship's new/create. + # The "Requested" toggle on the registration form still auto-creates a stub on + # save; this is the alternative where the admin fills in license/hours/cost up + # front. Hours/cost prefill from the event's offering. + def new + @ce_registration = @event_registration.continuing_education_registrations.build( + professional_license: @event_registration.registrant.professional_licenses.first, + hours: @event_registration.event.ce_hours_offered, + cost_cents: @event_registration.event.ce_hours_cost_cents + ) + authorize! @ce_registration + end + + def create + @ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create) + authorize! @ce_registration + + # One transaction so the new license (a build until now) and the CE registration + # persist together — a failed save leaves neither behind. + ActiveRecord::Base.transaction do + apply_ce_params(@ce_registration) + @ce_registration.save! + @event_registration.update_column(:ce_requested, true) + end + redirect_to registration_path, notice: "CE registration created.", status: :see_other + rescue ActiveRecord::RecordInvalid + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :new, status: :unprocessable_content + end + + def edit + authorize! @ce_registration + end + + def update + authorize! @ce_registration + + ActiveRecord::Base.transaction do + apply_ce_params(@ce_registration) + @ce_registration.save! + end + redirect_to registration_path, notice: "CE registration updated.", status: :see_other + rescue ActiveRecord::RecordInvalid + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :edit, status: :unprocessable_content + end + + # Removal mirrors scholarship's destroy but never cascades away a CE registration + # that carries payments — the admin must revert the allocation first. + def destroy + authorize! @ce_registration + if @ce_registration.allocations.exists? + redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), + alert: "Can't remove CE — it has payments. Revert the payment first.", status: :see_other + return + end + + registration = @ce_registration.event_registration + @ce_registration.destroy! + registration.update_column(:ce_requested, false) + redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other + end + + # Mark / unmark the CE certificate as issued (sets/clears certificate_sent_at), + # mirroring scholarship's toggle_tasks. + def toggle_certificate + authorize! @ce_registration + issued = @ce_registration.certificate_sent_at.present? + @ce_registration.update!(certificate_sent_at: issued ? nil : Time.current) + redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), + notice: issued ? "Certificate marked not issued." : "Certificate marked issued.", status: :see_other + end + + private + + def set_ce_registration + @ce_registration = ContinuingEducationRegistration.find(params[:id]) + end + + # The registration a new CE record attaches to, located from the signed global + # id the "Add CE registration" link carries (mirrors scholarship's allocatable). + def set_event_registration + sgid = params[:allocatable_sgid] || params.dig(:continuing_education_registration, :allocatable_sgid) + @event_registration = GlobalID::Locator.locate_signed(sgid) if sgid + redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration + end + + # License a brand-new CE registration attaches to: the registrant's existing + # license, else an unsaved build. assign_license fills it from the submitted + # type/number/state/expiry, and it persists with the CE registration in create's + # transaction — so abandoning the new form never leaves a stray license behind. + def license_for_create + @event_registration.registrant.professional_licenses.first || + @event_registration.registrant.professional_licenses.build + end + + # Apply the submitted license fields, hours, and cost to a CE registration. + # Shared by create and update so both read params the same way. + def apply_ce_params(ce_registration) + ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), + kind: params.dig(:continuing_education_registration, :license_kind), + issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), + expires_on: params.dig(:continuing_education_registration, :license_expires_on), + license_id: params.dig(:continuing_education_registration, :professional_license_id)) + ce_registration.hours = params.dig(:continuing_education_registration, :hours) + cost = params.dig(:continuing_education_registration, :cost_dollars) + ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? + end + + def registration_path + edit_event_registration_path(@ce_registration.event_registration) + end +end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index bf669fbded..fd700b3a87 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -60,6 +60,7 @@ def create authorize! @event_registration if @event_registration.save + reconcile_ce_registration if @event_registration.event&.ce_eligible? respond_to do |format| format.html { redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) @@ -87,22 +88,26 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save + reconcile_ce_registration if @event_registration.event&.ce_eligible? + # Prefer the CE flash ("CE registration created/removed") when reconcile set + # one; a blocked toggle-off sets flash[:alert], which survives independently. + notice = flash[:notice].presence || "Registration was successfully updated." respond_to do |format| format.turbo_stream format.html { case params[:return_to] - when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other - when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other - when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: "Registration was successfully updated.", status: :see_other + when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other + when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other + when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other + when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other + when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the # roster) rather than dropping them on the public registration show. if allowed_to?(:manage?, with: EventRegistrationPolicy) - redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other else - redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other end end } @@ -273,7 +278,11 @@ def unlink_organization def destroy authorize! @event_registration event = @event_registration.event - if @event_registration.destroy + if @event_registration.continuing_education_registrations.any? { |ce| ce.allocations.exists? } + # Deleting the registration would cascade away a paid CE registration (and its + # allocations); make the admin revert the payment first. + flash[:alert] = "Can't delete this registration while its CE registration has payments — revert the payment first." + elsif @event_registration.destroy flash[:notice] = "Registration deleted." else flash[:alert] = @event_registration.errors.full_messages.to_sentence @@ -323,17 +332,59 @@ def toggle_checklist_step(step, completed) end end + # Keep the registration's CE record in step with the `ce_requested` flag (set on + # the edit form / at intake), mirroring how a scholarship is awarded from its + # "Requested" toggle. Requested + none yet → create a stub against the chosen + # license (or the registrant's only license, else a placeholder); license/hours/ + # cost/certificate are then edited on the CE edit page. Un-requested → remove it, + # admins only, and never one that carries payments (the flag is restored and the + # admin is told to revert the payment first). Sets a flash describing what changed. + def reconcile_ce_registration + if @event_registration.ce_requested? + create_ce_registration_stub + else + remove_ce_registration + end + end + + def create_ce_registration_stub + return if @event_registration.continuing_education_registrations.exists? + + @event_registration.continuing_education_registrations.create!(professional_license: ce_license_for_create) + flash[:notice] = "CE registration created." + end + + def remove_ce_registration + registrations = @event_registration.continuing_education_registrations + return if registrations.none? || !allowed_to?(:manage?, with: EventRegistrationPolicy) + + if registrations.any? { |registration| registration.allocations.exists? } + @event_registration.update_column(:ce_requested, true) + flash[:alert] = "Can't remove CE — it has payments. Revert the payment first." + return + end + + registrations.destroy_all + flash[:notice] = "CE registration removed." + end + + # License a brand-new CE registration attaches to: the registrant's existing + # license, else an empty placeholder (number pending). Which license is used + # (and its number) is then changeable on the CE registration's edit page. + def ce_license_for_create + @event_registration.registrant.professional_licenses.first || + ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) + end + # Strong parameters def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, :scholarship_requested, + :ce_requested, :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 c736818163..c48bc94b32 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -58,10 +58,53 @@ 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 entry from the CE callout (type, number, issuing state, and + # expiry). Edits the license on the registrant's (first) CE registration in + # place, mirrors the number onto the registration's form answer, then returns to + # the callout. Plain full-page POST — no Turbo. Shares + # ContinuingEducationRegistration#assign_license with the admin edit page. + def update_ce_license + ce_registration = @event_registration.continuing_education_registrations.first + return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration + + # Once the certificate is issued the license is the credential it was issued + # under — frozen here. Admins can still correct it on the admin CE edit page. + if ce_registration.certificate_sent_at.present? + return redirect_to registration_ce_path(@event_registration.slug), + alert: "Your CE certificate has been issued, so the license can no longer be changed here. Contact us if it needs correcting." + end + + ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind], + issuing_state: params[:license_issuing_state], expires_on: params[:license_expires_on]) + ce_registration.save! + # assign_license already normalized the number; mirror the saved value rather + # than re-stripping the raw param. + record_ce_license_answer(ce_registration.professional_license.number) + + redirect_to registration_ce_path(@event_registration.slug), notice: "License saved." + rescue ActiveRecord::RecordInvalid + redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license." + end + + # Public CE opt-in from the callout: a registrant who didn't ask for credit at + # registration can request it here. Sets the flag and creates the CE + # registration (against a placeholder license; the number is entered next). + def request_ce + return redirect_to(registration_ce_path(@event_registration.slug)) unless @event.ce_eligible? + + @event_registration.update!(ce_requested: true) + unless @event_registration.continuing_education_registrations.exists? + license = ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) + @event_registration.continuing_education_registrations.create!(professional_license: license) + end + + redirect_to registration_ce_path(@event_registration.slug), notice: "Continuing education credit requested." + 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 @@ -113,6 +156,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 opens in # its 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 feacac6827..329f74845d 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 @@ -132,7 +130,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? @@ -186,7 +184,7 @@ def details def ce_hours authorize! @event, to: :ce_hours? - if @event.ce_hours_details.blank? + unless @event.ce_eligible? redirect_to event_path(@event, reg: params[:reg].presence) return end @@ -511,6 +509,18 @@ 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 + license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345") + @event_registration.continuing_education_registrations.build( + professional_license: license, + hours: @event.ce_hours_offered || 6, + cost_cents: @event.ce_hours_cost_cents || 15_000 + ) + 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 +636,11 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals row << (scholarship ? (scholarship.grant&.name.presence || "Unfunded") : "") row << onboarding_scholarship_tasks_csv(registration) if include_ce - 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? ? helpers.plain_number(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("; ") end EventRegistration::CHECKLIST_STEPS.each_key do |step| row << (registration.checklist_step_completed?(step) ? "Yes" : "No") diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index dbe7bd1158..7bdb0f5827 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -205,7 +205,9 @@ def update @person.avatar.purge end - @person.assign_attributes(person_params) + attrs = person_params + reject_locked_license_changes!(attrs) + @person.assign_attributes(attrs) @person.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user } @person.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user } @@ -382,6 +384,30 @@ def person_update_return_path end # Only allow a list of trusted parameters through. + # Server-side backstop for the per-license edit gating (ProfessionalLicensePolicy): + # drop any submitted change to a license the current user isn't allowed to edit or + # destroy — i.e. a CE-tied license edited by a non-admin owner. The view already + # renders those read-only; this stops a crafted request from slipping past. Admins + # are allowed everything, so it's a no-op for them (and thus for the admin-only form + # today). New licenses (no id) have no CE registrations yet, so they pass through. + def reject_locked_license_changes!(attrs) + license_attrs = attrs[:professional_licenses_attributes] + return if license_attrs.blank? + + license_attrs.keys.each do |key| + license = license_attrs[key] + id = license[:id] + next if id.blank? + + record = @person.professional_licenses.find_by(id: id) + next unless record + + destroying = license[:_destroy].to_s.in?(%w[ 1 true ]) + rule = destroying ? :destroy? : :update? + license_attrs.delete(key) unless allowed_to?(rule, record) + end + end + def person_params params.require(:person).permit( :avatar, @@ -391,7 +417,6 @@ def person_params :street_address, :city, :state, :zip, :country, :mailing_address_type, :best_time_to_call, :date_of_birth, - :credentials, :racial_ethnic_identity, :filemaker_code, :mailing_list_consented, @@ -489,6 +514,7 @@ def person_params :_destroy ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], + professional_licenses_attributes: [ :id, :number, :kind, :issuing_state, :expires_on, :_destroy ], ) end end diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index e46f6c7e31..bfb0f21d1e 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -1,4 +1,29 @@ class EventRegistrationDecorator < ApplicationDecorator + # The CE lifecycle badge, shared by every surface that shows CE status (the + # registrants index, the CE callout, the CE card on the registration edit page). + # One progression: Requested → License # needed → $X due → Pending → Issued. + # Pending is blue, Issued is green, every actionable/in-progress state is amber. + CeBadge = Struct.new(:label, :icon, :classes, keyword_init: true) + + CE_BADGE_CLASSES = { + green: "bg-green-50 text-green-700 border-green-200", + blue: "bg-blue-50 text-blue-700 border-blue-200", + amber: "bg-amber-50 text-amber-700 border-amber-200" + }.freeze + + # Nil when CE isn't in play (so the index can show a "Create" affordance instead). + # `simulate_paid:` lets the CE callout's ?admin=true preview the post-payment state + # without recording a payment. + def ce_status_badge(simulate_paid: false) + return unless ce_requested? || ce_registered? + return ce_badge("Requested", "fa-solid fa-clock", :amber) unless ce_registered? + return ce_badge("Issued", "fa-solid fa-circle-check", :green) if ce_certificate_issued? + return ce_badge("License # needed", "fa-solid fa-id-card", :amber) unless ce_license_provided? + return ce_badge("Pending", "fa-solid fa-hourglass-half", :blue) if ce_paid_in_full? || simulate_paid + + ce_badge("#{h.dollars_from_cents(ce_amount_due_cents)} due", "fa-solid fa-dollar-sign", :amber) + end + def title name end @@ -14,4 +39,10 @@ def default_display_image return event.primary_asset.file if event.respond_to?(:primary_asset) && event.primary_asset&.file&.attached? "theme_default.png" end + + private + + def ce_badge(label, icon, color) + CeBadge.new(label: label, icon: icon, classes: CE_BADGE_CLASSES.fetch(color)) + end end diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js deleted file mode 100644 index 58b2528798..0000000000 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Drives the CE-credit box on the registration form. Colors the "Requested" -// toggle to signal save state: amber while the choice is pending (changed but -// not yet saved), the continuing-education theme color once it matches the -// stored "on" value, neutral gray when stored as off. While "Requested" is on it -// reveals the CE details (license number + hours) and keeps the "Provided" badge -// and amount-owed total ($rate × hours) in sync as the admin edits them. -export default class extends Controller { - static targets = ["checkbox", "track", "details", "license", "licenseBadge", "hours", "amount"] - static values = { initial: Boolean, rate: Number } - - connect() { - this.refresh() - } - - refresh() { - const checked = this.checkboxTarget.checked - const pending = checked !== this.initialValue - - this.trackTarget.classList.toggle("bg-amber-500", pending) - this.trackTarget.classList.toggle("bg-teal-600", checked && !pending) - this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) - - if (this.hasDetailsTarget) this.detailsTarget.classList.toggle("hidden", !checked) - - this.updateLicenseBadge() - this.updateAmount() - } - - updateLicenseBadge() { - if (!this.hasLicenseTarget || !this.hasLicenseBadgeTarget) return - - const provided = this.licenseTarget.value.trim().length > 0 - this.licenseBadgeTarget.classList.toggle("bg-teal-50", provided) - this.licenseBadgeTarget.classList.toggle("text-teal-700", provided) - this.licenseBadgeTarget.classList.toggle("bg-gray-100", !provided) - this.licenseBadgeTarget.classList.toggle("text-gray-500", !provided) - this.licenseBadgeTarget.innerHTML = provided - ? ' Provided' - : ' Not provided' - } - - updateAmount() { - if (!this.hasHoursTarget || !this.hasAmountTarget) return - - const hours = Math.max(0, parseInt(this.hoursTarget.value, 10) || 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. - const fractionDigits = Number.isInteger(owed) ? 0 : 2 - this.amountTarget.textContent = `$${owed.toLocaleString("en-US", { minimumFractionDigits: fractionDigits, maximumFractionDigits: 2 })}` - } -} diff --git a/app/frontend/javascript/controllers/ce_license_picker_controller.js b/app/frontend/javascript/controllers/ce_license_picker_controller.js new file mode 100644 index 0000000000..40f56de15d --- /dev/null +++ b/app/frontend/javascript/controllers/ce_license_picker_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +// CE license picker. When the admin chooses a license from the dropdown, fill the +// type / number / state / expiry fields from that license (carried on each option's +// data attributes) so the fields describe the selected license. Choosing "Create +// new license" clears them for a fresh entry. +export default class extends Controller { + static targets = ["select", "kind", "number", "state", "expires"] + + sync() { + const option = this.selectTarget.selectedOptions[0] + if (!option) return + + const isNew = this.selectTarget.value === "new" + this.kindTarget.value = isNew ? "" : (option.dataset.kind || "") + this.numberTarget.value = isNew ? "" : (option.dataset.number || "") + this.stateTarget.value = isNew ? "" : (option.dataset.state || "") + this.expiresTarget.value = isNew ? "" : (option.dataset.expires || "") + } +} diff --git a/app/frontend/javascript/controllers/date_placeholder_controller.js b/app/frontend/javascript/controllers/date_placeholder_controller.js new file mode 100644 index 0000000000..7316dfa176 --- /dev/null +++ b/app/frontend/javascript/controllers/date_placeholder_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +// Greys a native date input's "mm/dd/yyyy" placeholder while it's empty. Date +// inputs have no ::placeholder and render the placeholder in the input's own text +// colour, so this toggles a grey text class on/off based on whether a date is set — +// keeping a chosen date dark while the empty prompt reads as a placeholder. +export default class extends Controller { + connect() { + this.refresh() + } + + refresh() { + this.element.classList.toggle("text-gray-400", this.element.value === "") + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 62b3cebdea..850448e889 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -33,6 +33,9 @@ application.register("callout-preview", CalloutPreviewController) import CarouselController from "./carousel_controller" application.register("carousel", CarouselController) +import CeLicensePickerController from "./ce_license_picker_controller" +application.register("ce-license-picker", CeLicensePickerController) + import ChipSelectController from "./chip_select_controller" application.register("chip-select", ChipSelectController) @@ -54,6 +57,9 @@ application.register("comment-edit-toggle", CommentEditToggleController) import CommentRequiredController from "./comment_required_controller" application.register("comment-required", CommentRequiredController) +import DatePlaceholderController from "./date_placeholder_controller" +application.register("date-placeholder", DatePlaceholderController) + import DirtyFormController from "./dirty_form_controller" application.register("dirty-form", DirtyFormController) @@ -138,15 +144,9 @@ application.register("rhino-source", RhinoSourceController) import ScholarshipPreviewController from "./scholarship_preview_controller" application.register("scholarship-preview", ScholarshipPreviewController) -import ScholarshipRequestedController from "./scholarship_requested_controller" -application.register("scholarship-requested", ScholarshipRequestedController) - import ScholarshipStatusToggleController from "./scholarship_status_toggle_controller" application.register("scholarship-status-toggle", ScholarshipStatusToggleController) -import CeCreditRequestedController from "./ce_credit_requested_controller" -application.register("ce-credit-requested", CeCreditRequestedController) - import SearchBoxController from "./search_box_controller" application.register("search-box", SearchBoxController) diff --git a/app/frontend/javascript/controllers/scholarship_requested_controller.js b/app/frontend/javascript/controllers/scholarship_requested_controller.js deleted file mode 100644 index 6e2e4473a5..0000000000 --- a/app/frontend/javascript/controllers/scholarship_requested_controller.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Colors the "Requested" toggle to signal save state: amber while the choice is -// pending (changed but not yet saved with the registration form), the -// scholarship theme color once it matches the stored "on" value, and neutral -// gray when stored as off. -export default class extends Controller { - static targets = ["checkbox", "track"] - static values = { initial: Boolean } - - connect() { - this.refresh() - } - - refresh() { - const checked = this.checkboxTarget.checked - const pending = checked !== this.initialValue - - this.trackTarget.classList.toggle("bg-amber-500", pending) - this.trackTarget.classList.toggle("bg-fuchsia-600", checked && !pending) - this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) - } -} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5d84242875..3e8c44b9a3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,6 +6,17 @@ module ApplicationHelper FORM_LABEL_TAGS = %w[br a p span strong b em i u h1 h2 h3 h4 h5 h6 ul ol li font].freeze FORM_LABEL_ATTRIBUTES = %w[href target rel style size color face].freeze + # Tint a section-header icon (the rounded square in a card header) with its + # domain theme colour only when that section actually holds data, falling back + # to a muted grey otherwise. Lets the registration edit cards signal at a glance + # which sections are populated — purely server-rendered, no JS. Pass the domain + # key (see DomainTheme::COLORS) and a boolean for whether the section has data. + def section_icon_class(domain, active) + return "bg-gray-100 text-gray-400" unless active + + "#{DomainTheme.bg_class_for(domain, intensity: 100)} #{DomainTheme.text_class_for(domain, intensity: 600)}" + end + # Render a form field name / header with a safe subset of HTML allowed. # Uses Rails' SafeListSanitizer, which strips dangerous URL schemes # (e.g. javascript:) from href and CSS-scrubs the style attribute (dropping @@ -451,6 +462,19 @@ def dollars_from_cents(cents) MoneyFormatter.dollars_from_cents(cents) end + # A plain number without insignificant trailing zeros (e.g. CE hours): 6.0 → "6", + # 1.5 → "1.5". Nil for a blank input so callers can render their own placeholder. + def plain_number(number) + NumberFormatter.plain(number) + end + + # Timezone hint phrased for whoever the form is about: second person when you're + # editing your own settings, third person ("User") when an admin edits someone else. + def timezone_visibility_hint(user) + subject = user == current_user ? "You" : "User" + "#{subject} will see times and dates in this timezone." + end + def navbar_bg_class if staging_environment? && !params[:nav_bg_primary].present? "bg-red-600" diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 25ac9f890a..6e61efefa4 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -1,10 +1,9 @@ module EventHelper # The "please specify" placeholder for an option label, or nil when the option - # does not reveal a free-text box. Pass the field to honor placeholders scoped - # to one field (e.g. the CE question's "Yes"). Canonical config lives on - # FormField (shared with answer validation). - def specify_placeholder(label, field = nil) - FormField.specify_placeholder_for(label, field&.field_identifier) + # does not reveal a free-text box. Canonical config lives on FormField (shared + # with answer validation). + def specify_placeholder(label) + FormField.specify_placeholder_for(label) end # True when a stored answer selects the given specify option: the bare label, diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 16d697dee8..8cb416c6a1 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -29,6 +29,56 @@ def certificate_available? event.end_date&.past? && event_registration.attended? && paid_in_full? end + # Point this registration at a license for the typed type + number. `license_id` + # comes from the form's license picker (shown when the registrant holds licenses): + # * an existing license → correct it in place from the typed fields (the picker + # populates those fields from whichever license is selected, so editing one + # and saving updates that license); + # * "new" → create a brand-new license for the person from the typed fields; + # * blank (no picker) → correct the current license in place (filling a blank + # placeholder or fixing a typo). + # In the "new"/"blank" cases an existing license already holding the typed number + # wins, to avoid duplicating or colliding on the unique (person, number) index. + # Does not save the registration itself — callers persist it alongside their other + # changes. + def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_id: nil) + number = number.to_s.strip.presence + kind = kind.to_s.strip.presence + issuing_state = issuing_state.to_s.strip.presence + expires_on = expires_on.presence + current = professional_license + person = event_registration.registrant + + if license_id.present? && license_id != "new" + picked = person.professional_licenses.find_by(id: license_id) + if picked + picked.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + self.professional_license = picked + return + end + end + + # Licenses are identified by (kind, number), so only an exact kind + number + # match is a duplicate to link to rather than create. + match = person.professional_licenses.where.not(id: current&.id).find_by(number: number, kind: kind) if number + if match + self.professional_license = match + else + target = license_id == "new" ? person.professional_licenses.new : current + target.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + self.professional_license = target + end + end + + # Human-readable payment status, mirroring EventRegistration#payment_status_label. + # CE has no "intends to pay" concept (that's an event-access affordance), so the + # middle state is a genuine partial payment instead. + def payment_status_label + return "Paid" if paid_in_full? + return "Partial" if partially_paid? + "Due" + end + private # Snapshot the hours offered and total cost from the event when they aren't set diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 96cb0e45ee..f032d0f04d 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -40,16 +40,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( @@ -147,18 +142,34 @@ class EventRegistration < ApplicationRecord else all end } - # Mirrors ReminderRecipientFilter#matches_ce_status?. The "license"/"hours" - # sub-statuses only make sense for someone who requested CE credit, so they're - # gated on it. "paid" has no CE-specific payment record yet, so it falls back - # to the registrant being paid in full. + # Filter by CE state. All derived (no stored CE status): payment (requested/paid) + # is computed from allocations vs cost like the registration's own payment state; + # issued/not_issued read the certificate delivery; needs_license is a CE + # registration sitting on a placeholder license. scope :ce_status, ->(value) { - case value - when "requested" then where(ce_credit_requested: true) - when "license_not_provided" then where(ce_credit_requested: true).where(ce_license_number: [ nil, "" ]) - when "hours_not_provided" then where(ce_credit_requested: true).where("COALESCE(ce_hours_requested, 0) <= 0") - when "paid" then where(ce_credit_requested: true).merge(paid_in_full) - else all - end + paid_sql = <<~SQL.squish + COALESCE((SELECT SUM(a.amount) FROM allocations a + WHERE a.allocatable_type = 'ContinuingEducationRegistration' + AND a.allocatable_id = cer.id), 0) >= cer.cost_cents + SQL + condition = + case value + when "needs_license" then "pl.number IS NULL" + when "paid" then paid_sql + when "requested" then "NOT (#{paid_sql})" + when "issued" then "cer.certificate_sent_at IS NOT NULL" + when "not_issued" then "cer.certificate_sent_at IS NULL" + end + next all if condition.blank? + + 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 (#{condition}) + ) + SQL } scope :comment_status, ->(value) { commented = Comment.where(commentable_type: "EventRegistration").select(:commentable_id) @@ -242,6 +253,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 @@ -314,26 +328,86 @@ def cost_cents event.cost_cents 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. + # + # `ce_requested?` is the stored intent flag (column); `ce_registered?` is whether + # a CE registration record actually exists. They align in the normal flow (the + # toggle creates the record), but the readers below key off the record. + def ce_registered? + 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 # A short label summarizing the registrant's CE credit standing, matching the - # ce_status filter buckets. Nil when CE credit was not requested, so callers - # can render a placeholder. "Incomplete" takes precedence over "Paid" because - # a missing license/hours is the actionable state regardless of payment. + # ce_status filter buckets. Nil when CE wasn't requested, so callers can render + # a placeholder. "Needs license" takes precedence (it's the actionable state), + # then certificate issuance, then payment. def ce_status_label - return unless ce_credit_requested? - return "Incomplete" if !ce_license_provided? || ce_hours_requested.to_i <= 0 - return "Paid" if paid_in_full? + return unless ce_registered? + return "Needs license" unless ce_license_provided? + return "Issued" if continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } + return "Paid" if ce_paid_in_full? "Requested" 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.cost_cents.to_i } + end + continuing_education_registrations.sum(:cost_cents) + end + + # Outstanding CE balance across this registration's CE registrations — cost net + # of payments/discounts, floored at zero. The "$X due" the registrant still owes; + # drops to zero once paid. remaining_cost is computed (not a column), so this sums + # in Ruby. + def ce_amount_due_cents + continuing_education_registrations.sum { |c| c.remaining_cost } + end + + # True only when every CE registration has a known license number on file. + def ce_license_provided? + return false unless ce_registered? + + continuing_education_registrations.all? { |c| c.professional_license&.number_known? } + end + + # True when CE is registered and every CE registration's certificate has been + # issued (sent) — the terminal state of the CE lifecycle. + def ce_certificate_issued? + return false unless ce_registered? + + continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } + end + + # True when a CE registration exists and every one is fully paid. + def ce_paid_in_full? + return false unless ce_registered? + + 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..78bb92bdce 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -66,17 +66,6 @@ class FormField < ApplicationRecord "Foundation/Funder" => "Please list the name of the foundation/funder" }.freeze - # 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 - # Fallback character ceilings applied when a free-form field has no explicit # max_characters set. This is a safety net against pathological submissions # (megabyte answers that bloat the DB and break admin/email rendering), not a @@ -343,20 +332,17 @@ def dynamic_categories # The "please specify" placeholder for an option label, or nil when the option # does not reveal a free-text box. Matched case- and whitespace-insensitively - # against SPECIFY_OPTION_PLACEHOLDERS, then (when a field_identifier is given) - # against that field's FIELD_SPECIFY_OPTION_PLACEHOLDERS, which wins. - def self.specify_placeholder_for(label, field_identifier = nil) + # against SPECIFY_OPTION_PLACEHOLDERS. + def self.specify_placeholder_for(label) normalized = label.to_s.strip return if normalized.blank? - field_scoped = FIELD_SPECIFY_OPTION_PLACEHOLDERS[field_identifier]&.find { |key, _| key.casecmp?(normalized) }&.last - field_scoped || SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last + SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last end - # True when an option label reveals a free-text "please specify" box, optionally - # scoped to a field via its field_identifier. - def self.specify_option?(label, field_identifier = nil) - specify_placeholder_for(label, field_identifier).present? + # True when an option label reveals a free-text "please specify" box. + def self.specify_option?(label) + specify_placeholder_for(label).present? end # True when an option label is the generic free-text "Other" choice. Unlike @@ -375,7 +361,7 @@ def self.other_option?(label) # their options, so they expose no specify option. def specify_option_labels option_names = dynamic_options? ? dynamic_option_names : answer_options.map(&:name) - option_names.select { |name| FormField.specify_option?(name, field_identifier) } + option_names.select { |name| FormField.specify_option?(name) } end # The names of this field's dynamically-sourced options (Sector or Category), diff --git a/app/models/person.rb b/app/models/person.rb index 99a4886830..5b1b828cf7 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -70,6 +70,10 @@ class Person < ApplicationRecord accepts_nested_attributes_for :affiliations, allow_destroy: true, reject_if: proc { |attrs| attrs["organization_id"].blank? } accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } + # A blank row (number + kind + state + expiry all empty) is ignored rather than + # creating an empty license. + accepts_nested_attributes_for :professional_licenses, allow_destroy: true, + reject_if: proc { |attrs| attrs.slice("number", "kind", "issuing_state", "expires_on").values.all?(&:blank?) } # Search Cop include SearchCop @@ -180,6 +184,13 @@ def full_name "#{first_name} #{last_name}" end + # Distinct professional-license types (e.g. "LMFT, LCSW"), shown as a credential + # suffix after the person's name on their profile (replaces the old free-text + # credentials field). Nil when no licensed types are on file. + def license_credentials + professional_licenses.filter_map { |license| license.kind.presence&.strip }.uniq.join(", ").presence + end + def full_name_with_email email = preferred_email name = full_name diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index 0f07ff774c..59971b92e8 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -7,7 +7,15 @@ class ProfessionalLicense < ApplicationRecord has_many :continuing_education_registrations, dependent: :destroy - validates :number, uniqueness: { scope: :person_id }, allow_nil: true + # Deleting a license cascades to its CE registrations (and their allocations), + # so refuse to remove one that has any CE registration at all — regardless of + # whether those registrations carry payments. prepend so this runs before the + # dependent: :destroy cascade below clears those registrations. + before_destroy :prevent_destroy_with_ce, prepend: true + + # A license is identified by its kind + number, so the same number under two + # 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 @@ -30,4 +38,26 @@ def expired? def name [ kind, number ].compact_blank.join(" ").presence || "License (number pending)" end + + # True when this license has no CE registrations and so can be deleted without + # cascading away CE history — used to gate the remove control on the person edit + # form. Once any CE registration exists (paid or not), the license is permanent. + def removable? + continuing_education_registrations.none? + end + + # Has this license been used for CE credit? Drives edit gating: a license tied to + # any CE registration is locked to admins (see ProfessionalLicensePolicy). + def used_for_ce? + continuing_education_registrations.exists? + end + + private + + def prevent_destroy_with_ce + return if removable? + + errors.add(:base, "Can't remove a license with CE registrations.") + throw :abort + end end diff --git a/app/policies/continuing_education_registration_policy.rb b/app/policies/continuing_education_registration_policy.rb new file mode 100644 index 0000000000..4e9cf4dd7d --- /dev/null +++ b/app/policies/continuing_education_registration_policy.rb @@ -0,0 +1,11 @@ +class ContinuingEducationRegistrationPolicy < ApplicationPolicy + # The CE registration edit page (license/hours/cost, certificate issuance, + # removal) is an admin management surface, like scholarships. Registrants edit + # their own license number via the public CE callout, not here. + def new? = admin? + def create? = admin? + def edit? = admin? + def update? = admin? + def destroy? = admin? + def toggle_certificate? = admin? +end diff --git a/app/policies/professional_license_policy.rb b/app/policies/professional_license_policy.rb new file mode 100644 index 0000000000..48d5e53137 --- /dev/null +++ b/app/policies/professional_license_policy.rb @@ -0,0 +1,28 @@ +class ProfessionalLicensePolicy < ApplicationPolicy + # A license is managed (added/edited) by an admin, or by the person who holds it + # — but only while it has no CE registrations against it. Once any CE + # registration exists the license locks to admins, since a registrant must not + # alter the credentials a CE certificate was (or will be) issued under. + # + # NOTE: the person edit form is admin-only today (PersonPolicy#edit?/#update?), + # so this gating is dormant for now. It's in place so that when that policy opens + # to owners, CE-tied licenses are already protected — no change needed here. + def edit? + admin? || (owner? && !record.used_for_ce?) + end + alias_rule :new?, :create?, :update?, to: :edit? + + # Deleting a license cascades to its CE registrations, so it's never removable + # once any CE registration is attached — for admins and owners alike. + def destroy? + edit? && record.removable? + end + + private + + # The holder of the license, via their user account (Person has_one :user). + def owner? + return false unless user + record.person&.user == user + end +end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 6dc60d988a..6870d52d9e 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,32 @@ 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, + ce_requested: ce_credit_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 a040933a86..2d5182bee5 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -119,8 +119,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_registered? + 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, @@ -133,8 +133,10 @@ def ce_hours_card end def ce_hours_subtitle - return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? - "Continuing education credit" + total = registration.ce_hours_total + return "Continuing education credit" unless total.positive? + + "#{NumberFormatter.plain(total)} hours" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an @@ -153,11 +155,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/number_formatter.rb b/app/services/number_formatter.rb new file mode 100644 index 0000000000..85e4cecaec --- /dev/null +++ b/app/services/number_formatter.rb @@ -0,0 +1,15 @@ +# Centralizes how a plain number renders without insignificant trailing zeros, so +# models, services, helpers, and decorators all format the same way (e.g. CE hours: +# 6.0 => "6", 1.5 => "1.5", 0.25 => "0.25"). View code should call the +# `plain_number` helper, which delegates here; models and other POROs (no +# view-helper access) call NumberFormatter directly. Mirrors MoneyFormatter. +class NumberFormatter + # The number as a string with trailing zeros dropped. Nil for a blank input so + # callers can render their own placeholder. + def self.plain(number) + return if number.blank? + + value = number.to_f + value == value.to_i ? value.to_i.to_s : value.to_s + end +end diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb new file mode 100644 index 0000000000..8f382beb9f --- /dev/null +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -0,0 +1,100 @@ +<%# CE details form fields (license type/number/state/expiry + hours + cost), + shared by the new and edit pages. Renders inside a form_with block — `f` is the + builder. Locals: f, license (may be nil on new), ce_registration, event. %> +<% licenses = ce_registration.event_registration.registrant.professional_licenses.order(:kind, :number) %> +
"> +
+ + + +

CE details

+
+ +
+ <%# License picker — shown only when the registrant holds more than one license, + to choose which to load into the fields (or "Create new license"). With zero + or one license there's no picker and the fields edit/create it directly. %> + <% if licenses.many? %> +
+ + <%= f.select :professional_license_id, + options_for_select( + licenses.map { |l| [ l.name, l.id, { "data-kind" => l.kind, "data-number" => l.number, "data-state" => l.issuing_state, "data-expires" => l.expires_on&.iso8601 } ] } + + [ [ "Create new license", "new" ] ], + license&.id + ), + {}, + id: "continuing_education_registration_professional_license_id", + data: { "ce-license-picker-target": "select", action: "ce-license-picker#sync" }, + 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" %> +

Pick a license to load it into the fields below, or "Create new license" to add one.

+
+ <% end %> + +
+
+
+ + <%= f.text_field :license_kind, value: license&.kind, id: "continuing_education_registration_license_kind", + placeholder: "e.g. LCSW", data: { "ce-license-picker-target": "kind" }, + 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" %> +
+
+ + <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", + placeholder: "e.g. 12345", data: { "ce-license-picker-target": "number" }, + 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" %> +
+
+ + <%= f.select :license_issuing_state, + us_states, + { selected: license&.issuing_state, include_blank: "Select a state" }, + id: "continuing_education_registration_license_issuing_state", + data: { "ce-license-picker-target": "state" }, + 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" %> +
+
+ + <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", + data: { "ce-license-picker-target": "expires" }, + 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" %> +
+
+ <% if license&.number_known? %> +

Editing the type or number corrects the license already on file.

+ <% else %> +

No license on file yet — add the type and number here.

+ <% end %> +
+ +
+
+
+ + <%= f.number_field :hours, value: plain_number(ce_registration.hours), min: 0, step: 0.25, + id: "continuing_education_registration_hours", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.number_field :cost_dollars, value: (ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, + id: "continuing_education_registration_cost_dollars", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%# Event baseline both fields default to (snapshotted onto the registration + at creation). Sits tight under the row like a hint, but applies to both + fields so an admin can see the standard offering and tell when this + registrant's hours/cost have been customized. %> + <% if event.ce_hours_offered.present? || event.ce_hours_cost_cents.present? %> +

+ Event default: + <%= plain_number(event.ce_hours_offered) || "—" %> hrs + · <%= event.ce_hours_cost_cents.present? ? dollars_from_cents(event.ce_hours_cost_cents) : "—" %> +

+ <% end %> +
+
+
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb new file mode 100644 index 0000000000..52220a407b --- /dev/null +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -0,0 +1,79 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% registration = @ce_registration.event_registration %> +<% license = @ce_registration.professional_license %> + +
+ <%# Top bar: back link + secondary links, matching the scholarship edit page %> +
+ <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Registration + <% end %> +
+ <%# Admin jump to the registrant-facing CE callout (what the registrant sees / + where they enter their own license). Opens in a new tab so the edit context + is kept; matches the sky admin-link convention. %> + <%= link_to registration_ce_path(registration.slug, return_to: "ce_registration"), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Registrant's CE page + + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
+
+ + <%= render "shared/event_page_header", + title: "Edit CE registration", + icon: "fa-solid fa-certificate", + color: :continuing_education, + event: registration.event, + person: registration.registrant %> + +
+ <%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, id: "ce_registration_form", data: { turbo: false } do |f| %> + <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> + <% end %> + +
+
+ + + +

Certificate

+
+ +
+

+ <% if @ce_registration.certificate_sent_at.present? %> + Issued <%= @ce_registration.certificate_sent_at.to_date.to_fs(:long) %> + <% else %> + Not issued yet + <% end %> +

+ <%= button_to toggle_certificate_continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, class: "rounded-lg border px-3 py-1.5 text-sm font-medium #{@ce_registration.certificate_sent_at.present? ? "border-gray-300 text-gray-600 hover:bg-gray-50" : "border-teal-300 bg-teal-50 text-teal-700 hover:bg-teal-100"} cursor-pointer" do %> + <%= @ce_registration.certificate_sent_at.present? ? "Mark not issued" : "Mark certificate issued" %> + <% end %> +
+
+ + <%# ---- Footer actions ---- %> +
+ <%= button_to continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :delete, form: { data: { turbo_confirm: "Remove this CE registration?" } }, + class: "btn btn-danger-outline" do %> + + Delete + <% end %> + +
+ <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> + <%# Submits the CE details form above (which the certificate section sits outside of). %> + +
+
+
+ + <%= render "shared/audit_info", resource: @ce_registration %> +
diff --git a/app/views/continuing_education_registrations/new.html.erb b/app/views/continuing_education_registrations/new.html.erb new file mode 100644 index 0000000000..1ce90560dd --- /dev/null +++ b/app/views/continuing_education_registrations/new.html.erb @@ -0,0 +1,36 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% registration = @event_registration %> +<% event = registration.event %> + +
+ <%# Top bar: back link + Home, matching the CE edit page. %> +
+ <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Registration + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ + <%= render "shared/event_page_header", + title: "Add CE registration", + icon: "fa-solid fa-certificate", + color: :continuing_education, + event: event, + person: registration.registrant %> + +
+ <%= form_with model: @ce_registration, url: continuing_education_registrations_path, + method: :post, id: "ce_registration_form", data: { turbo: false } do |f| %> + <%= hidden_field_tag :allocatable_sgid, registration.to_sgid.to_s %> + <%= render "details_section", f: f, license: @ce_registration.professional_license, ce_registration: @ce_registration, event: event %> + <% end %> + + <%# ---- Footer actions ---- %> +
+
+ <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> + +
+
+
+
diff --git a/app/views/event_registrations/_ce_status_badge.html.erb b/app/views/event_registrations/_ce_status_badge.html.erb new file mode 100644 index 0000000000..2ae94fe973 --- /dev/null +++ b/app/views/event_registrations/_ce_status_badge.html.erb @@ -0,0 +1,23 @@ +<%# The canonical CE status badge. Locals: + registration – the EventRegistration + href – wrap the pill in a link to this path (optional) + simulate_paid – preview the post-payment state (CE callout's ?admin=true) + Renders nothing when CE isn't in play; callers handle that case (e.g. the + registrants index shows a "Create" affordance instead). %> +<% href = local_assigns[:href] %> +<% badge = registration.decorate.ce_status_badge(simulate_paid: local_assigns.fetch(:simulate_paid, false)) %> +<% if badge %> + <% base = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border text-xs font-medium #{badge.classes}" %> + <% if href %> + <%= link_to href, class: "#{base} px-5 py-0.5 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> + + <%= badge.label %> + + <% end %> + <% else %> + + + <%= badge.label %> + + <% end %> +<% end %> diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb new file mode 100644 index 0000000000..a6d56e56b7 --- /dev/null +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -0,0 +1,72 @@ +<%# ---- Continuing education — mirrors the scholarship card. "Requested" is a plain + flag saved with the form; saving it on (when none exists) creates a CE + registration stub, which is then filled in on its own edit page. Once a record + exists the toggle is gone and we show the record + an Edit link. Only rendered + for CE-eligible events. ---- %> +
+
+ + + +

Continuing education

+
+ +
+ <% unless ce_registration %> + + + <%# Two ways to create the record, mirroring the scholarship card: the + Requested toggle above auto-creates a stub on save, while "Add CE + registration" opens the full new form (license/hours/cost) in a new tab + and returns here. %> +
+ <%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", + target: "_blank", rel: "noopener" do %> + + Add CE registration + + <% end %> +
+ <% else %> + <% license = ce_registration.professional_license %> +
+
+ <%= dollars_from_cents(ce_registration.cost_cents) %> + · + <%= plain_number(ce_registration.hours) || "0" %> hrs + <%= link_to edit_continuing_education_registration_path(ce_registration, return_to: "registration"), + class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", + target: "_blank", rel: "noopener" do %> + Edit + + <% end %> +
+ +

+ License: + <% if license&.number_known? %> + <%= license.name %> + <% else %> + Needs license + <% end %> +

+ + <%# Pinned to the card's bottom so it lines up with the scholarship card's + chip and the organizations card's "Connect organization" link. %> +
+ <%= render "event_registrations/ce_status_badge", registration: event_registration %> +
+
+ <% end %> +
+
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 9cd5247c47..e39d6a680a 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -110,7 +110,7 @@ <% if f.object.slug.present? %> <%= link_to registration_ticket_path(f.object.slug), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", data: { turbo_frame: "_top" } do %> @@ -121,12 +121,12 @@ <% submissions.each_with_index do |(form_name, ts), i| %> <%= link_to event_public_registration_path(f.object.event, **form_show_params), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", title: "#{form_name} — submitted #{ts.strftime('%b %-d, %Y')}", data: { turbo_frame: "_top" } do %> - View submission<%= " ##{i + 1}" if submissions.size > 1 %> + View form submission<%= " ##{i + 1}" if submissions.size > 1 %> <% end %> <% end %> @@ -138,16 +138,21 @@ <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %>
-
+
- + -

Registration organizations

+ <%# Keep "Registration-linked" intact on line one (no break at the hyphen) and + let "organizations" wrap to line two in the narrow card header. %> +

Registration-linked organizations

-
-
> +
+ <%# Nudge the chips up by the org card's extra header line (its title wraps + to two lines) so the first chip's top lines up with the scholarship + card's "$100". %> +
>
<% f.object.organizations.sort_by(&:name).each do |org| %>
<% if f.object.payment_unresolved? %> @@ -300,7 +246,7 @@ <%# ---- Comments ---- %>
- +

Registration comments

@@ -349,7 +295,7 @@ their profile via nested attributes. ---- %>
- +

Shout out

@@ -363,7 +309,7 @@ value="1" <%= "checked" if f.object.shoutout? %> class="sr-only peer"> - + Feature on the recipients page diff --git a/app/views/event_registrations/_notifications.html.erb b/app/views/event_registrations/_notifications.html.erb index c673c4a96a..765baa409d 100644 --- a/app/views/event_registrations/_notifications.html.erb +++ b/app/views/event_registrations/_notifications.html.erb @@ -8,7 +8,7 @@ <% notifications = email.present? ? Notification.email(email).order(created_at: :desc) : Notification.none %>
- +

Registration communications

diff --git a/app/views/event_registrations/_payment_history.html.erb b/app/views/event_registrations/_payment_history.html.erb index 0986c050eb..73708d1ef9 100644 --- a/app/views/event_registrations/_payment_history.html.erb +++ b/app/views/event_registrations/_payment_history.html.erb @@ -8,7 +8,7 @@ <% return if cost_cents <= 0 && event_registration.allocations.empty? %>
- +

Registration payments and allocations

diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 38d8d1bbfe..665f0cf253 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -2,34 +2,30 @@ not create or destroy an award on its own. Awarding is a deliberate action via "Add scholarship". On save, the controller cleans up an unrequested scholarship only when it is an empty stub (no amount, tasks incomplete). ---- %> -
+
- +

Scholarship

-
+
<% unless scholarship %> -
<% else %> -

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

+

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

+ <%= 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 %> <% end %> <% if @event.ce_hours_details.present? %> -
- <%= form_label_html(@event.ce_hours_details) %> -
+
+

Details

+
+ <%= form_label_html(@event.ce_hours_details) %> +
+
<% end %> <% end %> diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index 7ff8e0b178..0f6260a572 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -1,10 +1,21 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Scholarship — #{@event.title}") %> +<%# Reached from the admin scholarship edit page (return_to=scholarship): point the + eyebrow back there instead of the registrant's ticket. Gated on edit access so a + registrant who lands on this URL still gets the default ticket back. %> +<% from_scholarship_edit = params[:return_to] == "scholarship" && @scholarship && allowed_to?(:edit?, @scholarship) %>
- <%= link_to registration_ticket_path(@event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - Back to ticket + <% if from_scholarship_edit %> + <%= link_to edit_scholarship_path(@scholarship), + class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> + Back to scholarship + <% end %> + <% else %> + <%= link_to registration_ticket_path(@event_registration.slug), + class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> + Back to ticket + <% end %> <% end %>
@@ -24,6 +35,19 @@
+ <% if @scholarship && allowed_to?(:edit?, @scholarship) %> + <%# Admin-only jump to the management surface for this scholarship. Hidden from + registrants; opens in a new tab so the registrant view is kept. %> +
+ <%= link_to edit_scholarship_path(@scholarship), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Edit scholarship + + <% end %> +
+ <% end %> + <% if @scholarship %> <% grant = @scholarship.grant %>
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 19ee6ad93f..6177e81b70 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? ? plain_number(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 cost", 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/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 7bec3c4bb7..9605cfe1ca 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -78,10 +78,10 @@ data and store none of their own, so fall back to those when present. %> <% radio_options = dynamic_form_field_options(field) || field.form_field_answer_options.includes(:answer_option).map { |ffao| [ ffao.answer_option.name, ffao.answer_option.name ] } %> - <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label, field).present? } %> + <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label).present? } %> <%= tag.div class: "flex flex-wrap gap-2.5 mt-1", data: (has_specify ? { controller: "specify-option", action: "change->specify-option#update" } : {}) do %> <% radio_options.each do |option_label, option_value, option_description| %> - <% placeholder = specify_placeholder(option_label, field) %> + <% placeholder = specify_placeholder(option_label) %> <% is_specify = placeholder.present? %>
@@ -331,6 +326,23 @@
+
+
+ Professional licenses +
+
+

Used for continuing-education credit. Once a license has CE registrations it can't be removed, and only an admin can edit it.

+ <%= f.simple_fields_for :professional_licenses do |license_form| %> + <%= render "professional_licenses/professional_license_fields", f: license_form %> + <% end %> + <%= link_to_add_association "➕ Add license", + f, + :professional_licenses, + partial: "professional_licenses/professional_license_fields", + class: "btn btn-secondary-outline" %> +
+
+
@@ -358,7 +370,7 @@ collection: us_time_zone_fundamentals, selected: user_form.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: "Event times and other dates will be shown in this timezone.", + hint: timezone_visibility_hint(user_form.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full sm:flex-1" } %> <% end %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 2aaad7ddc3..230ed8b9e6 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -64,7 +64,7 @@

- <%= @person.name %><% if @person.credentials.present? && @person.profile_show_credentials? %>, <%= @person.credentials %><% end %> + <%= @person.name %><% if @person.profile_show_credentials? && @person.license_credentials.present? %>, <%= @person.license_credentials %><% end %> <% if @person.pronouns.present? && @person.profile_show_pronouns? %> (<%= @person.pronouns %>) <% end %> diff --git a/app/views/professional_licenses/_professional_license_fields.html.erb b/app/views/professional_licenses/_professional_license_fields.html.erb new file mode 100644 index 0000000000..36133980df --- /dev/null +++ b/app/views/professional_licenses/_professional_license_fields.html.erb @@ -0,0 +1,83 @@ +<%# A license is editable by an admin, or by its holder while it has no CE + registrations. Once CE registrations exist it locks to admins — shown read-only + with admin-only styling. (Today the whole person form is admin-only, so the + locked branch only surfaces once PersonPolicy opens editing to owners.) + simple_fields_for still emits the hidden id in the locked branch, so the record + submits unchanged. %> +<% editable = f.object.new_record? || allowed_to?(:edit?, f.object) %> +<% if editable %> +
+
+
+ <%= f.input :number, + wrapper: false, + label: "License number", + input_html: { placeholder: "e.g. 90210", class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :kind, + wrapper: false, + label: "Type", + input_html: { placeholder: "e.g. LMFT", class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :issuing_state, + as: :select, + collection: us_states, + include_blank: true, + wrapper: false, + label: "Issuing state", + input_html: { class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :expires_on, + as: :date, + html5: true, + wrapper: false, + label: "Expires", + input_html: { class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500", + data: { controller: "date-placeholder", action: "input->date-placeholder#refresh change->date-placeholder#refresh" } } %> +
+ +
+ <% if f.object.new_record? || f.object.removable? %> + <%= link_to_remove_association "Remove", + f, + class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap" %> + <% else %> + Has CE registrations — can't remove + <% end %> +
+
+
+<% else %> +
+
+
+
+
License number
+
<%= f.object.number.presence || "—" %>
+
+
+
Type
+
<%= f.object.kind.presence || "—" %>
+
+
+
Issuing state
+
<%= f.object.issuing_state.presence || "—" %>
+
+
+
Expires
+
<%= f.object.expires_on&.to_fs(:long) || "—" %>
+
+
+ + Admin only + +
+

Locked because this license has CE registrations — only an admin can edit it.

+
+<% end %> diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index 27a3311abb..c478953e97 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -36,6 +36,17 @@ <% end %> <% end %>
+ <% if @allocatable.respond_to?(:event) %> + <%# Admin jump to the registrant-facing scholarship callout (what the registrant + sees). Opens in a new tab so the edit context is kept; matches the sky + admin-link convention and the CE edit page's "Registrant's CE page". %> + <%= link_to registration_scholarship_path(@allocatable.slug, return_to: "scholarship"), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Registrant's scholarship page + + <% end %> + <% end %> <%= link_to "View", scholarship_path(@scholarship), class: "text-sm text-gray-500 hover:text-gray-700" %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 07c9665ff0..bdb4d95f6b 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -105,7 +105,7 @@ collection: us_time_zone_fundamentals, selected: f.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: "Event times and other dates will be shown in this timezone.", + hint: timezone_visibility_hint(f.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 94fef6ba86..e128981e31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -83,6 +83,8 @@ 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 + post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request 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 @@ -116,6 +118,9 @@ resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member { patch :toggle_tasks } end + resources :continuing_education_registrations, only: [ :new, :create, :edit, :update, :destroy ] do + member { patch :toggle_certificate } + end resources :discounts, only: [ :create, :show, :destroy ] do collection do post :allocation_form diff --git a/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb new file mode 100644 index 0000000000..029e38859d --- /dev/null +++ b/db/migrate/20260629122601_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/migrate/20260629173817_add_ce_requested_to_event_registrations.rb b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb new file mode 100644 index 0000000000..8d294a7fc9 --- /dev/null +++ b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb @@ -0,0 +1,14 @@ +class AddCeRequestedToEventRegistrations < ActiveRecord::Migration[8.1] + # The CE "Requested" intent flag, mirroring scholarship_requested / w9_requested + # / invoice_requested. It's the toggle the registrant/admin sets; the + # ContinuingEducationRegistration record is the fulfillment, created from it. + def up + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end + + def down + remove_column :event_registrations, :ce_requested, if_exists: true + end +end diff --git a/db/migrate/20260630133153_remove_credentials_from_people.rb b/db/migrate/20260630133153_remove_credentials_from_people.rb new file mode 100644 index 0000000000..fb0886729c --- /dev/null +++ b/db/migrate/20260630133153_remove_credentials_from_people.rb @@ -0,0 +1,12 @@ +class RemoveCredentialsFromPeople < ActiveRecord::Migration[8.1] + # The free-text credentials field is no longer edited or displayed — the profile + # credential suffix now derives from the person's professional-license types + # (Person#license_credentials). The profile_show_credentials toggle stays. + def up + remove_column :people, :credentials, if_exists: true + end + + def down + add_column :people, :credentials, :string unless column_exists?(:people, :credentials) + end +end diff --git a/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb new file mode 100644 index 0000000000..856a7c646a --- /dev/null +++ b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb @@ -0,0 +1,10 @@ +class ScopeProfessionalLicenseUniquenessToKind < ActiveRecord::Migration[8.1] + # A license is identified by kind + number, not number alone, so "LMFT 12345" + # and "LCSW 12345" are distinct. Widen the per-person unique index. + def change + remove_index :professional_licenses, [ :person_id, :number ], unique: true, + name: "index_professional_licenses_on_person_and_number" + add_index :professional_licenses, [ :person_id, :kind, :number ], unique: true, + name: "index_professional_licenses_on_person_kind_number" + end +end diff --git a/db/schema.rb b/db/schema.rb index c902433e94..c38c0e0bb3 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_29_023519) do +ActiveRecord::Schema[8.1].define(version: 2026_06_30_220947) 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 @@ -478,9 +478,7 @@ 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.boolean "ce_requested", default: false, null: false t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false @@ -967,7 +965,6 @@ t.boolean "blog_contributor", default: false, null: false t.datetime "created_at", null: false t.integer "created_by_id" - t.string "credentials" t.date "date_of_birth" t.string "display_name_preference" t.string "email" @@ -1032,7 +1029,7 @@ t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["created_by_id"], name: "index_professional_licenses_on_created_by_id" - t.index ["person_id", "number"], name: "index_professional_licenses_on_person_and_number", unique: true + t.index ["person_id", "kind", "number"], name: "index_professional_licenses_on_person_kind_number", unique: true t.index ["person_id"], name: "index_professional_licenses_on_person_id" t.index ["updated_by_id"], name: "index_professional_licenses_on_updated_by_id" end diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 90fb18e91a..a9835220df 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 @@ -769,10 +769,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_offered: 6, ce_hours_cost_cents: 15_000) [ - { 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 } @@ -788,8 +789,9 @@ # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training + trauma_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { 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" } @@ -858,9 +860,17 @@ # 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) + # "issued" in the seed data means the CE certificate was delivered. + ce_registration.mark_certificate_sent! if data[:ce_status] == "issued" + end end # Connect each multi-affiliation registrant's registration to a single one of diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb new file mode 100644 index 0000000000..45e6d8028c --- /dev/null +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe EventRegistrationDecorator do + describe "#ce_status_badge" do + subject(:badge) { registration.decorate.ce_status_badge(**opts) } + + let(:registration) { create(:event_registration) } + let(:opts) { {} } + + def add_ce(number: "LIC-1", cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: number) + create(:continuing_education_registration, event_registration: registration, + professional_license: license, cost_cents: cost_cents) + end + + def pay(cer, amount) + payment = create(:payment, person: registration.registrant, amount_cents: amount, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: amount) + end + + context "when CE isn't in play" do + it { is_expected.to be_nil } + end + + context "when requested but no CE registration exists yet" do + before { registration.update!(ce_requested: true) } + + it "is an amber Requested badge" do + expect(badge.label).to eq("Requested") + expect(badge.classes).to include("amber") + end + end + + context "when a CE registration sits on a placeholder license" do + before do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + it "is an amber License # needed badge" do + expect(badge.label).to eq("License # needed") + expect(badge.classes).to include("amber") + end + end + + context "when the license is on file but unpaid" do + before { add_ce(cost_cents: 15_000) } + + it "shows the balance due in amber" do + expect(badge.label).to eq("$150 due") + expect(badge.classes).to include("amber") + end + + context "with simulate_paid" do + let(:opts) { { simulate_paid: true } } + + it "previews the blue Pending state" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + end + + context "when paid in full but not issued" do + before { pay(add_ce(cost_cents: 15_000), 15_000) } + + it "is a blue Pending badge" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + + context "when the certificate has been issued" do + before do + cer = add_ce(cost_cents: 15_000) + pay(cer, 15_000) + cer.mark_certificate_sent! + end + + it "is a green Issued badge" do + expect(badge.label).to eq("Issued") + expect(badge.classes).to include("green") + end + end + end +end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 31f720e5cf..85b9223e3d 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -1,6 +1,21 @@ require "rails_helper" RSpec.describe ApplicationHelper, type: :helper do + describe "#timezone_visibility_hint" do + let(:me) { create(:user) } + let(:other) { create(:user) } + + before { allow(helper).to receive(:current_user).and_return(me) } + + it "uses second person when the form is about the current user" do + expect(helper.timezone_visibility_hint(me)).to eq("You will see times and dates in this timezone.") + end + + it "uses 'User' when an admin edits someone else" do + expect(helper.timezone_visibility_hint(other)).to eq("User will see times and dates in this timezone.") + end + end + describe "#dollars_from_cents" do it "drops the cents for whole-dollar amounts and adds thousands separators" do expect(helper.dollars_from_cents(150_000)).to eq("$1,500") diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 8426f5f677..d06f3bbbe5 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -19,13 +19,6 @@ expect(helper.specify_placeholder("Other reasons")).to be_nil expect(helper.specify_placeholder(nil)).to be_nil end - - it "returns the CE hours placeholder for 'Yes' only on the CE field" 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 - end end describe "#specify_option_selected?" do diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index e90ab505d1..a2e5bea409 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -186,5 +186,28 @@ def scholarship_for(reg, amount) expect(queries).to be_empty expect(preloaded.paid_in_full?).to be(true) end + + it "reports discounted? and discount_sum like an event registration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.discounted?).to be(false) + expect(ce_reg.discount_sum).to eq(0) + + create(:allocation, source: create(:discount, amount_cents: 4_000), allocatable: ce_reg, amount: 4_000) + + expect(ce_reg.discounted?).to be(true) + expect(ce_reg.discount_sum).to eq(4_000) + end + + it "labels payment status Due → Partial → Paid" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.payment_status_label).to eq("Due") + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 4_000) + expect(ce_reg.payment_status_label).to eq("Partial") + + create(:allocation, source: payment, allocatable: ce_reg, amount: 6_000) + expect(ce_reg.payment_status_label).to eq("Paid") + end end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index cdc22ea13f..030ac2e10b 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -176,41 +176,68 @@ end describe ".ce_status" do - let!(:complete_ce) do - create(:event_registration, event: event, ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3).tap do |r| - create(:allocation, source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), - allocatable: r, amount: event.cost_cents) + let(:ce_cost) { 15_000 } + # Known license, fully paid (certificate not yet issued). + let!(:paid_ce) do + create(:event_registration, event: event).tap do |r| + cer = create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + create(:allocation, source: create(:payment, amount_cents: ce_cost, amount_cents_remaining: ce_cost), + allocatable: cer, amount: ce_cost) end end - let!(:missing_ce) { create(:event_registration, event: event, ce_credit_requested: true) } - let!(:no_ce) { create(:event_registration, event: event, ce_credit_requested: false) } + # Known license, unpaid. + let!(:requested_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + end + end + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_license_ce) do + create(:event_registration, event: event).tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, cost_cents: ce_cost) + end + end + # Certificate delivered. + let!(:issued_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost, certificate_sent_at: Time.current) + end + end + let!(:no_ce) { create(:event_registration, event: event) } - it "maps 'requested' to anyone who asked for CE credit" do - results = EventRegistration.ce_status("requested") - expect(results).to include(complete_ce, missing_ce) - expect(results).not_to include(no_ce) + it "maps 'needs_license' to CE on a placeholder license" do + results = EventRegistration.ce_status("needs_license") + expect(results).to include(needs_license_ce) + expect(results).not_to include(paid_ce, requested_ce, no_ce) end - it "maps 'license_not_provided' to CE requests missing a license number" do - results = EventRegistration.ce_status("license_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'paid' to fully paid CE registrations" do + results = EventRegistration.ce_status("paid") + expect(results).to include(paid_ce) + expect(results).not_to include(requested_ce, no_ce) end - it "maps 'hours_not_provided' to CE requests missing hours" do - results = EventRegistration.ce_status("hours_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'requested' to CE registrations not yet paid" do + results = EventRegistration.ce_status("requested") + expect(results).to include(requested_ce, needs_license_ce) + expect(results).not_to include(paid_ce, no_ce) end - it "maps 'paid' to CE requests that are paid in full" do - results = EventRegistration.ce_status("paid") - expect(results).to include(complete_ce) - expect(results).not_to include(missing_ce, no_ce) + it "maps 'issued' to CE registrations with a delivered certificate" do + results = EventRegistration.ce_status("issued") + expect(results).to include(issued_ce) + expect(results).not_to include(requested_ce, no_ce) + end + + it "maps 'not_issued' to CE registrations without a delivered certificate" do + results = EventRegistration.ce_status("not_issued") + expect(results).to include(paid_ce, requested_ce) + expect(results).not_to include(issued_ce, no_ce) end it "returns an unfiltered relation for unknown values" do - expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) + expect(EventRegistration.ce_status("bogus")).to include(paid_ce, requested_ce, no_ce) end end @@ -571,33 +598,66 @@ describe "continuing education" do let(:reg) { create(:event_registration) } + def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) + license = create(:professional_license, person: reg.registrant, number: number) + create(:continuing_education_registration, event_registration: reg, professional_license: license, hours: hours, cost_cents: cost_cents) + 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 cost across the registration's CE registrations" do + add_ce(cost_cents: 10_000) + expect(reg.ce_amount_owed_cents).to eq(10_000) 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_registered?" do + it "is true only once a CE registration exists" do + expect(reg).not_to be_ce_registered + add_ce + expect(reg.reload).to be_ce_registered + 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 + 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 + + describe "#ce_amount_due_cents" do + it "is the cost not yet covered by payments, floored at zero" do + cer = add_ce(cost_cents: 15_000) + expect(reg.ce_amount_due_cents).to eq(15_000) + + payment = create(:payment, person: reg.registrant, amount_cents: 6_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 6_000) + expect(reg.reload.ce_amount_due_cents).to eq(9_000) + end + + it "is zero once fully paid" do + cer = add_ce(cost_cents: 15_000) + payment = create(:payment, person: reg.registrant, amount_cents: 15_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 15_000) + expect(reg.reload.ce_amount_due_cents).to eq(0) 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_certificate_issued?" do + it "is true only once every CE registration's certificate is sent" do + cer = add_ce + expect(reg.reload).not_to be_ce_certificate_issued + cer.mark_certificate_sent! + expect(reg.reload).to be_ce_certificate_issued 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/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index 9ed1565e62..ad43d763b6 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -45,11 +45,60 @@ end describe "validations" do - it "rejects a duplicate number for the same person" do - create(:professional_license, person: person, number: "DUP") - dup = build(:professional_license, person: person, number: "DUP") + it "rejects a duplicate kind + number for the same person" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + dup = build(:professional_license, person: person, kind: "LMFT", number: "DUP") expect(dup).not_to be_valid end + + it "allows (and persists) the same number under a different kind" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + + expect { + create(:professional_license, person: person, kind: "LCSW", number: "DUP") + }.to change(described_class, :count).by(1) + end + end + + describe "removal guard" do + let(:license) { create(:professional_license, person: person, number: "GUARD-1") } + + def attach_ce(paid: false) + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + return ce unless paid + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + ce + end + + it "is removable with no CE registrations" do + expect(license).to be_removable + expect { license.destroy }.to change(described_class, :count).by(-1) + end + + it "refuses to destroy when an unpaid CE registration exists" do + attach_ce(paid: false) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + + it "refuses to destroy when a CE registration carries payments" do + attach_ce(paid: true) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + end + + describe "#used_for_ce?" do + let(:license) { create(:professional_license, person: person, number: "USED-1") } + + it "is false without CE registrations and true once one exists" do + expect(license).not_to be_used_for_ce + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(license.reload).to be_used_for_ce + end end end diff --git a/spec/policies/professional_license_policy_spec.rb b/spec/policies/professional_license_policy_spec.rb new file mode 100644 index 0000000000..13668c33d1 --- /dev/null +++ b/spec/policies/professional_license_policy_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe ProfessionalLicensePolicy, type: :policy do + let(:owner) { create(:user, :with_person) } + let(:admin) { create(:user, :admin) } + let(:other) { create(:user, :with_person) } + + let(:license) { create(:professional_license, person: owner.person, number: "POL-1") } + + def policy_for(user:, record: license) + described_class.new(record, user: user) + end + + def attach_ce + registration = create(:event_registration, registrant: owner.person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + describe "#edit? / #update?" do + context "an admin" do + it "may always edit, even with CE registrations" do + expect(policy_for(user: admin)).to be_allowed_to(:edit?) + attach_ce + expect(policy_for(user: admin, record: license.reload)).to be_allowed_to(:edit?) + end + end + + context "the holder (owner)" do + it "may edit a license with no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:edit?) + expect(policy_for(user: owner)).to be_allowed_to(:update?) + end + + it "may not edit once the license has CE registrations" do + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:edit?) + end + end + + context "an unrelated user" do + it "may not edit" do + expect(policy_for(user: other)).not_to be_allowed_to(:edit?) + end + end + end + + describe "#destroy?" do + it "is allowed for the owner only when the license has no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:destroy?) + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:destroy?) + end + + it "is blocked even for an admin once a CE registration exists" do + attach_ce + expect(policy_for(user: admin, record: license.reload)).not_to be_allowed_to(:destroy?) + end + end +end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb new file mode 100644 index 0000000000..581969594e --- /dev/null +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -0,0 +1,178 @@ +require "rails_helper" + +RSpec.describe "ContinuingEducationRegistrations", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let(:registration) { create(:event_registration, event: event, ce_requested: true) } + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + describe "as an admin" do + before { sign_in admin } + + it "renders the edit page" do + get edit_continuing_education_registration_path(ce_registration) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Edit CE registration") + end + + it "updates hours, cost, and fills the placeholder license type, number, state + expiry in place" do + license = ce_registration.professional_license + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } + + ce_registration.reload + expect(ce_registration.hours).to eq(4.5) + expect(ce_registration.cost_cents).to eq(9_000) + # Placeholder is filled in place rather than orphaned. + expect(ce_registration.professional_license).to eq(license) + expect(license.reload).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) + end + + it "edits the same license in place when correcting a typo (no new record)" do + license = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "11223") + ce_registration.update!(professional_license: license) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LCSW", license_number: "11224" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(license) + expect(license.reload.number).to eq("11224") + end + + it "links to the registrant's existing license when the typed number already matches one" do + ce_registration + other = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "99887") + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LMFT", license_number: "99887" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(other) + end + + it "marks the certificate issued and back to not issued" do + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_present + + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_nil + end + + it "renders the new page for a registration" do + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Add CE registration") + end + + it "creates a CE registration with license, hours, and cost, and sets the flag" do + registration + + expect { + post continuing_education_registrations_path, + params: { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } + }.to change { registration.continuing_education_registrations.count }.by(1) + + ce = registration.continuing_education_registrations.last + expect(response).to redirect_to(edit_event_registration_path(registration)) + expect(ce.hours).to eq(4.5) + expect(ce.cost_cents).to eq(9_000) + expect(ce.professional_license).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) + expect(registration.reload.ce_requested).to be(true) + end + + it "creates no license just from opening the new form" do + registration + expect { + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + }.not_to change(ProfessionalLicense, :count) + end + + it "leaves no orphan license when the create fails validation" do + registration + params = { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "-5", license_kind: "LMFT", license_number: "555" } } + + expect { + post continuing_education_registrations_path, params: params + }.to change(ProfessionalLicense, :count).by(0) + expect(ContinuingEducationRegistration.count).to eq(0) + expect(response).to have_http_status(:unprocessable_content) + end + + it "renders the license picker on new when the registrant holds more than one license" do + create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + create(:professional_license, person: registration.registrant, kind: "LCSW", number: "222") + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response.body).to include("professional_license_id") + expect(response.body).to include("Create new license") + end + + it "omits the license picker when the registrant has a single license" do + create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response.body).not_to include("professional_license_id") + end + + it "points the registration at a picked license and edits it in place from the fields" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + b = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "222") + ce_registration.update!(professional_license: a) + + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: b.id, + license_kind: "LCSW", license_number: "222-B", license_issuing_state: "NY", hours: "6", cost_dollars: "120" } } + + expect(ce_registration.reload.professional_license).to eq(b) + expect(b.reload).to have_attributes(kind: "LCSW", number: "222-B", issuing_state: "NY") + expect(a.reload).to have_attributes(kind: "LMFT", number: "111") + end + + it "creates a new license when 'Create new license' is picked" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + ce_registration.update!(professional_license: a) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: "new", + license_kind: "LPCC", license_number: "333", hours: "6", cost_dollars: "120" } } + }.to change(ProfessionalLicense, :count).by(1) + + expect(ce_registration.reload.professional_license).to have_attributes(kind: "LPCC", number: "333") + expect(a.reload).to have_attributes(kind: "LMFT", number: "111") + end + + it "removes a CE registration with no payments and clears the flag" do + ce_registration + delete continuing_education_registration_path(ce_registration) + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(false) + expect(registration.reload.ce_requested).to be(false) + end + + it "refuses to remove a CE registration that has payments" do + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce_registration, amount: 12_000) + + delete continuing_education_registration_path(ce_registration) + + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(true) + expect(flash[:alert]).to match(/has payments/) + end + end + + it "forbids non-admins" do + sign_in create(:user) + get edit_continuing_education_registration_path(ce_registration) + expect(response).not_to have_http_status(:ok) + end +end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index f1d7361466..16710368e4 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,26 @@ 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 stub when the ce_requested flag is set" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 12_000) 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) + ce_registration = existing_registration.reload.continuing_education_registrations.first + expect(ce_registration).to be_present + # Hours/cost default from the event; the license is a placeholder until set. + expect(ce_registration.hours).to eq(6) + expect(ce_registration.professional_license.number).to be_nil end - it "updates the CE hours and license number" do + it "creates the CE registration against the registrant's existing license" do + event.update!(ce_hours_offered: 6) + license = create(:professional_license, person: existing_registration.registrant, number: "LIC-987") + 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" } } - existing_registration.reload - expect(existing_registration.ce_hours_requested).to eq(5) - expect(existing_registration.ce_license_number).to eq("LIC-987") + expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(license) end it "sets the shout-out flag and stores the shout-out text on the registrant" do @@ -356,6 +373,17 @@ def unrequest(registration) delete event_registration_path(existing_registration) }.to change(EventRegistration, :count).by(-1) end + + it "refuses to delete a registration whose CE registration has payments" do + ce = create(:continuing_education_registration, event_registration: existing_registration, cost_cents: 12_000) + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce, amount: 12_000) + + expect { + delete event_registration_path(existing_registration) + }.not_to change(EventRegistration, :count) + expect(flash[:alert]).to match(/has payments/) + end end describe "organization linking" 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 f9ecb2b57b..a205bfe77b 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -162,6 +162,29 @@ expect(response.body).not_to include("Review your form responses") end + + it "shows an admin edit-scholarship link and a back-to-scholarship eyebrow when reached from there" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + sign_in create(:user, :with_person, super_user: true) + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Edit scholarship") + expect(response.body).to include("Back to scholarship") + expect(response.body).to include(edit_scholarship_path(scholarship)) + end + + it "keeps the default ticket eyebrow for a registrant even with the scholarship origin" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to scholarship") + expect(response.body).not_to include("Edit scholarship") + end end describe "GET /registration/:slug/faq" do @@ -221,20 +244,139 @@ describe "GET /registration/:slug/ce" do 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") + it "shows status, cost, and the license number on file, read-only with an edit link" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + 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") + # License on file but unpaid → the badge shows the balance due. + expect(response.body).to include("$150 due") + expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") + # Read-only by default: the form is not rendered, just an Edit link that flips to it. + expect(response.body).to include("editing=license") + expect(response.body).not_to include("Save changes") + end + + it "flips to the editable form when reached with ?editing=license" do + 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, editing: "license") + expect(response.body).to include("Save changes") + expect(response.body).to include("Cancel") 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) + it "shows blank license fields and a needs-license prompt when nothing is on file yet" do + 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("License type") + expect(response.body).to include("Your license number") + expect(response.body).to include("License # needed") + expect(response.body).to include("We need your license type and number") + expect(response.body).to include("Save changes") + # Nothing on file yet, so there's no read-only value to edit or cancel back to. + expect(response.body).not_to include("editing=license") + expect(response.body).not_to include(">Cancel<") + end + + it "shows an admin jump link to the CE registration only to admins" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include(edit_continuing_education_registration_path(ce)) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug) + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "lets an admin preview the paid (Pending) state with ?admin=true" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("Pending") + expect(response.body).not_to include("$150 due") + end + + it "ignores ?admin=true for a registrant (no access)" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + 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, admin: "true") + expect(response.body).to include("$150 due") + expect(response.body).not_to include("Pending") + end + + it "points the eyebrow back to the CE registration when reached from there" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to CE registration") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "keeps the default ticket eyebrow for a registrant even with the ce_registration origin" do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to CE registration") + end + end + + describe "POST /registration/:slug/ce/license" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "saves the license type, number, issuing state, and expiry 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_kind: "LMFT", license_number: "7788", + license_issuing_state: "CA", license_expires_on: "2027-01-31" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + saved = registration.continuing_education_registrations.first.professional_license + expect(saved.kind).to eq("LMFT") + expect(saved.number).to eq("7788") + expect(saved.issuing_state).to eq("CA") + expect(saved.expires_on).to eq(Date.new(2027, 1, 31)) + end + + it "refuses to change the license once the certificate is issued" do + license = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + create(:continuing_education_registration, event_registration: registration, professional_license: license, + hours: 6, certificate_sent_at: Time.current) + + post registration_ce_license_path(registration.slug), params: { license_kind: "LCSW", license_number: "999" } + + expect(license.reload).to have_attributes(kind: "LMFT", number: "111") + expect(flash[:alert]).to match(/certificate has been issued/) + end + end + + describe "POST /registration/:slug/ce/request" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person, ce_requested: false) } + + it "opts the registrant into CE and creates the registration" do + expect { + post registration_ce_request_path(registration.slug) + }.to change { registration.reload.continuing_education_registrations.count }.from(0).to(1) + + expect(registration.ce_requested).to be(true) + expect(response).to redirect_to(registration_ce_path(registration.slug)) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 9f879ce72c..b520530560 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -165,15 +165,15 @@ def offer_ce!(target_event) 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_offered: 6, 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 @@ -851,25 +851,30 @@ def submit_agency_name(name) end describe "GET /events/:id/registrants with the CE status filter" do - let(:event) { create(:event, cost_cents: 1_000) } - let(:complete_person) { create(:person, first_name: "Complete", last_name: "Person") } - let(:missing_person) { create(:person, first_name: "Missing", last_name: "Person") } + let(:event) { offer_ce!(create(:event, cost_cents: 1_000)) } + let(:paid_person) { create(:person, first_name: "Paid", last_name: "Person") } + let(:needs_person) { create(:person, first_name: "Needs", last_name: "License") } let(:none_person) { create(:person, first_name: "Noce", last_name: "Person") } - let!(:complete_reg) do - reg = create(:event_registration, event: event, registrant: complete_person, - ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, source: create(:payment, amount_cents: 1_000, amount_cents_remaining: 1_000), - allocatable: reg, amount: 1_000) + # Known license, fully paid. + let!(:paid_reg) do + reg = create(:event_registration, event: event, registrant: paid_person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) reg end - let!(:missing_reg) { create(:event_registration, event: event, registrant: missing_person, ce_credit_requested: true) } - let!(:none_reg) { create(:event_registration, event: event, registrant: none_person, ce_credit_requested: false) } - - before do - offer_ce!(event) - sign_in admin + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_reg) do + reg = create(:event_registration, event: event, registrant: needs_person) + create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, :placeholder, person: needs_person)) + reg end + # No CE registration. + let!(:none_reg) { create(:event_registration, event: event, registrant: none_person) } + + before { sign_in admin } it "shows the CE status column and filter when the event offers CE" do get registrants_event_path(event) @@ -886,29 +891,23 @@ def submit_agency_name(name) expect(response.body).to include('data-column-toggle-group-value="ce"') end - it "filters to all CE requests" do + it "filters to CE registrations not yet paid" do get registrants_event_path(event, ce_status: "requested") - expect(response.body).to include("Complete Person") - expect(response.body).to include("Missing Person") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") expect(response.body).not_to include("Noce Person") end - it "filters to CE requests missing a license number" do - get registrants_event_path(event, ce_status: "license_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") - end - - it "filters to CE requests missing hours" do - get registrants_event_path(event, ce_status: "hours_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") + it "filters to CE registrations on a placeholder license" do + get registrants_event_path(event, ce_status: "needs_license") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") end - it "filters to paid CE requests" do + it "filters to paid CE registrations" do get registrants_event_path(event, ce_status: "paid") - expect(response.body).to include("Complete Person") - expect(response.body).not_to include("Missing Person") + expect(response.body).to include("Paid Person") + expect(response.body).not_to include("Needs License") end it "does not crash on an invalid ce_status" do @@ -916,9 +915,9 @@ def submit_agency_name(name) expect(response).to have_http_status(:ok) end - it "hides CE entirely when the event's registration form doesn't offer CE" do + it "hides CE entirely when the event does not offer CE" do plain_event = create(:event) - create(:event_registration, event: plain_event, ce_credit_requested: true) + create(:event_registration, event: plain_event) get registrants_event_path(plain_event) expect(response.body).not_to include("CE status") end @@ -926,7 +925,7 @@ def submit_agency_name(name) it "includes a CE status column in the CSV export" do get registrants_event_path(event, format: :csv) expect(response.body).to include("CE status") - expect(response.body).to include("Incomplete") + expect(response.body).to include("Needs license") end end @@ -942,42 +941,47 @@ def ce_chip_text Nokogiri::HTML(response.body).at_css('td[data-column-toggle-col="ce"]')&.text&.squish end - it "shows Create when CE was not requested" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + it "shows Create when no CE registration exists" do + create(:event_registration, event: event, registrant: person) get registrants_event_path(event) expect(ce_chip_text).to eq("Create") end - it "shows Requested when requested but no CE registration record exists yet" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: true) - get registrants_event_path(event) - expect(ce_chip_text).to eq("Requested") - end - - it "shows No license # once a CE record exists without a license number" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows License # needed once a CE record exists without a license number" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, professional_license: create(:professional_license, :placeholder, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("No license #") + expect(ce_chip_text).to eq("License # needed") end - it "shows Filed once a license is on file but the CE balance is unpaid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows the balance due once a license is on file but the CE balance is unpaid" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("Filed") + expect(ce_chip_text).to eq("$150 due") + end + + it "shows Pending when the CE balance is paid but the certificate isn't issued" do + reg = create(:event_registration, event: event, registrant: person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, person: person)) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) + get registrants_event_path(event) + expect(ce_chip_text).to eq("Pending") end - it "shows Recipient when the CE balance is paid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows Issued once the CE certificate has been delivered" do + reg = create(:event_registration, event: event, registrant: person) cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), allocatable: cer, amount: 15_000) + cer.mark_certificate_sent! get registrants_event_path(event) - expect(ce_chip_text).to eq("Recipient") + expect(ce_chip_text).to eq("Issued") end end diff --git a/spec/requests/people_professional_licenses_spec.rb b/spec/requests/people_professional_licenses_spec.rb new file mode 100644 index 0000000000..bf94569c00 --- /dev/null +++ b/spec/requests/people_professional_licenses_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +RSpec.describe "People professional licenses", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + it "renders the professional licenses section on the edit page" do + person = create(:person) + create(:professional_license, person: person, number: "LMFT 90210") + + get edit_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Professional licenses") + expect(response.body).to include("LMFT 90210") + end + + it "adds a license through the person form" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "LCSW 11223", kind: "LCSW", issuing_state: "CA" } } + } } + }.to change { person.professional_licenses.count }.by(1) + + expect(person.professional_licenses.last.number).to eq("LCSW 11223") + end + + it "ignores a blank license row" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "", kind: "", issuing_state: "", expires_on: "" } } + } } + }.not_to change { person.professional_licenses.count } + end + + it "removes a license with no CE registrations" do + person = create(:person) + license = create(:professional_license, person: person, number: "GONE-1") + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + }.to change { person.professional_licenses.count }.by(-1) + end + + it "keeps a license whose CE registration has no payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "UNPAID-1") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + it "keeps a license whose CE registration has payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "PAID-1") + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + # The person form is admin-only today, so the per-license guard is dormant. These + # simulate the future state where PersonPolicy lets an owner reach the form, to + # prove the server-side backstop (ProfessionalLicensePolicy) holds before we flip it. + context "when a non-admin owner can reach the form (simulating the future PersonPolicy)" do + let(:owner) { create(:user, :with_person) } + let(:person) { owner.person } + + before do + sign_in owner + allow_any_instance_of(PersonPolicy).to receive(:edit?).and_return(true) + allow_any_instance_of(PersonPolicy).to receive(:update?).and_return(true) + end + + it "ignores edits to a CE-tied license but applies edits to an unlocked one" do + locked = create(:professional_license, person: person, number: "LOCKED-1", kind: "LMFT") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + unlocked = create(:professional_license, person: person, number: "OPEN-1", kind: "LCSW") + + patch person_path(person), params: { person: { + professional_licenses_attributes: { + "0" => { id: locked.id, kind: "HACKED" }, + "1" => { id: unlocked.id, kind: "LPCC" } + } + } } + + expect(locked.reload.kind).to eq("LMFT") + expect(unlocked.reload.kind).to eq("LPCC") + end + + it "blocks deleting a CE-tied license" do + locked = create(:professional_license, person: person, number: "LOCKED-2") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: locked.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(locked.id)).to be(true) + end + end +end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb index 20f46c5bfe..dc93bb6ab8 100644 --- a/spec/requests/people_profile_flags_spec.rb +++ b/spec/requests/people_profile_flags_spec.rb @@ -64,12 +64,12 @@ end describe "#profile_show_credentials" do - before { person.update!(credentials: "LCSW") } + before { create(:professional_license, person: person, kind: "LCSW", number: "11223") } context "when false" do before { person.update!(profile_show_credentials: false) } - it "hides credentials on own profile" do + it "hides the license credentials on own profile" do sign_in owner_user get person_path(person) expect(response.body).not_to include("LCSW") @@ -79,13 +79,13 @@ context "when true" do before { person.update!(profile_show_credentials: true) } - it "shows credentials as a suffix on own profile" do + it "shows the license type as a suffix on own profile" do sign_in owner_user get person_path(person) expect(response.body).to include("LCSW") end - it "shows credentials when admin views profile" do + it "shows the license type when admin views profile" do sign_in admin get person_path(person) expect(response.body).to include("LCSW") diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index abe26dd270..8f67138038 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_offered: 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 90c3ac64ad..86e078c948 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -60,31 +60,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_offered: 6, ce_hours_cost_cents: 15_000) + 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") @@ -117,10 +111,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_offered: 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/number_formatter_spec.rb b/spec/services/number_formatter_spec.rb new file mode 100644 index 0000000000..d1b4b6b650 --- /dev/null +++ b/spec/services/number_formatter_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe NumberFormatter do + describe ".plain" do + it "drops insignificant trailing zeros" do + expect(described_class.plain(6.0)).to eq("6") + expect(described_class.plain(1.5)).to eq("1.5") + expect(described_class.plain(0.25)).to eq("0.25") + expect(described_class.plain(BigDecimal("6"))).to eq("6") + end + + it "returns nil for a blank input" do + expect(described_class.plain(nil)).to be_nil + expect(described_class.plain("")).to be_nil + end + end +end diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 140d5cdf31..b19f039392 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -167,32 +167,42 @@ 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.cost_cents, amount_cents_remaining: ce_reg.cost_cents) + create(:allocation, source: payment, allocatable: ce_reg, amount: ce_reg.cost_cents) 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(:regs) { [ complete, missing, no_ce ] } - - it "filters CE requested" do - expect(matched({ ce_status: "requested" }, regs)).to eq([ complete.id, missing.id ].to_set) + # CE 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 + # CE with a license on file but the balance unpaid. + let!(:unpaid_known) do + registration(first_name: "Unpaid").tap do |r| + license = create(:professional_license, person: r.registrant, number: "XYZ789") + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end end + # No CE registration at all. + let!(:no_ce) { registration(first_name: "None") } + let(:regs) { [ complete, missing, unpaid_known, no_ce ] } - it "filters CE license not provided (only among CE requesters)" do - expect(matched({ ce_status: "license_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE not yet paid" do + expect(matched({ ce_status: "requested" }, regs)).to eq([ missing.id, unpaid_known.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) + it "filters CE on a placeholder license" do + expect(matched({ ce_status: "needs_license" }, regs)).to eq([ missing.id ].to_set) end - it "filters CE paid (requested CE and paid in full)" do + it "filters CE paid (CE balance paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index 216bca7b60..886114afaa 100644 --- a/spec/system/event_registration_edit_spec.rb +++ b/spec/system/event_registration_edit_spec.rb @@ -117,7 +117,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_text("Funded by") + expect(page).to have_text("Grantor:") expect(page).to have_link("Acme Foundation", href: organization_path(organization)) end end @@ -136,7 +136,7 @@ end end - it "omits the funder line when the scholarship has no grant" do + it "shows no grantor text (just a spacer) when the scholarship has no grant" do scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 1_000) create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) @@ -144,7 +144,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_no_text("Funded by") + expect(page).to have_no_text("Grantor:") end 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( diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index a4980c7bc4..7aa69257cd 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -170,6 +170,8 @@ "app/views/category_types/edit.html.erb" => "admin-only bg-blue-100", "app/views/community_news/edit.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/new.html.erb" => "admin-only bg-blue-100", "app/views/events/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100",