From 7697dd378cd90570e6c07d777d9bc4b2e5f4560d Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 25 Jun 2026 11:34:45 -0400 Subject: [PATCH 01/13] Add ProfessionalLicense and ContinuingEducationRegistration models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CE data was three flat columns on EventRegistration, which can't express that CE hours are tracked per professional license, that a person holds several licenses, or that a CE record has its own payment and certificate lifecycle. This lands the foundation: - ProfessionalLicense (per Person; nullable number = placeholder; find_or_create_for keeps one license per number) - ContinuingEducationRegistration (per EventRegistration, against one license; allocatable like a registration; string status, no enum) - Event#ce_hours_eligible + ce_hours as the source of CE availability/hours - Allocation over-payment guard + requested→paid status sync for CE - Drop the now-unused Person#license_number/license_type columns Additive: the existing ce_* columns and CE form are untouched (rerouted in a follow-up PR). Also adds an "avoid Rails enums" project convention. Co-Authored-By: Claude Opus 4.8 --- .github/copilot-instructions.md | 1 + AGENTS.md | 2 + CLAUDE.md | 1 + app/controllers/people_controller.rb | 2 - app/models/allocation.rb | 30 +++++++++ .../continuing_education_registration.rb | 67 +++++++++++++++++++ app/models/event.rb | 8 +++ app/models/event_registration.rb | 1 + app/models/person.rb | 1 + app/models/professional_license.rb | 33 +++++++++ app/policies/event_policy.rb | 2 + app/views/people/_form.html.erb | 12 ---- ...0625130421_create_professional_licenses.rb | 25 +++++++ ...625152706_add_ce_hours_fields_to_events.rb | 14 ++++ ...eate_continuing_education_registrations.rb | 23 +++++++ ...52708_remove_license_fields_from_people.rb | 13 ++++ db/schema.rb | 44 +++++++++++- .../continuing_education_registrations.rb | 7 ++ spec/factories/professional_licenses.rb | 12 ++++ spec/models/allocation_spec.rb | 26 +++++++ .../continuing_education_registration_spec.rb | 64 ++++++++++++++++++ spec/models/event_spec.rb | 11 +++ spec/models/professional_license_spec.rb | 55 +++++++++++++++ 23 files changed, 437 insertions(+), 17 deletions(-) create mode 100644 app/models/continuing_education_registration.rb create mode 100644 app/models/professional_license.rb create mode 100644 db/migrate/20260625130421_create_professional_licenses.rb create mode 100644 db/migrate/20260625152706_add_ce_hours_fields_to_events.rb create mode 100644 db/migrate/20260625152707_create_continuing_education_registrations.rb create mode 100644 db/migrate/20260625152708_remove_license_fields_from_people.rb create mode 100644 spec/factories/continuing_education_registrations.rb create mode 100644 spec/factories/professional_licenses.rb create mode 100644 spec/models/continuing_education_registration_spec.rb create mode 100644 spec/models/professional_license_spec.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8dc942bd03..e7b7afbda9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -47,6 +47,7 @@ When changing a model or controller, check whether these related files need upda - Prefer early returns and guard clauses - Avoid unnecessary and/or complex conditionals - Prefer constants and scopes over magic strings +- Avoid Rails `enum` — prefer plain string columns constrained by a constant + `validates inclusion` - Use safe navigation (`&.`) where appropriate - Use `presence` over blank checks - Use `Arel.sql` for raw SQL in order clauses diff --git a/AGENTS.md b/AGENTS.md index 0443af703b..99475fe138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,8 @@ This codebase (Rails 8.1) | `Organization` | Groups with affiliations, addresses, logos via ActiveStorage | | `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount | | `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` | +| `ProfessionalLicense` | A license a `Person` holds (`number`, `kind`, `issuing_state`, `expires_on`); a null `number` is a placeholder. `find_or_create_for` keeps one license per (person, number) | +| `ContinuingEducationRegistration` | A registrant's CE for one event against one `ProfessionalLicense`; billable `allocatable` (`hours × rate`), string `status` (requested/paid/issued/unawarded) | | `Report` | STI base class for MonthlyReport | | `WorkshopLog` | Standalone model for workshop log submissions (attendance, form fields) | diff --git a/CLAUDE.md b/CLAUDE.md index 826f37677b..dbd052f2ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ When changing a model or controller, check whether these related files need upda - Prefer early returns and guard clauses - Avoid unnecessary and/or complex conditionals - Prefer constants and scopes over magic strings +- Avoid Rails `enum` — prefer plain string columns constrained by a constant + `validates inclusion` - Use safe navigation (`&.`) where appropriate - Use `presence` over blank checks - Use `Arel.sql` for raw SQL in order clauses diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index f1eadfdf2f..dbe7bd1158 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -391,8 +391,6 @@ def person_params :street_address, :city, :state, :zip, :country, :mailing_address_type, :best_time_to_call, :date_of_birth, - :license_number, - :license_type, :credentials, :racial_ethnic_identity, :filemaker_code, diff --git a/app/models/allocation.rb b/app/models/allocation.rb index b18137d9b2..e917b62a48 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -12,8 +12,10 @@ class Allocation < ApplicationRecord validate :reverted_requires_positive_amount, :negative_cannot_be_reverted validate :validate_event_registration_cost, if: -> { allocatable_type == "EventRegistration" } + validate :validate_ce_registration_cost, if: -> { allocatable_type == "ContinuingEducationRegistration" } after_create :adjust_source_remaining + after_create :sync_ce_registration_status def adjust_source_remaining return unless source.is_a?(Payment) @@ -21,6 +23,11 @@ def adjust_source_remaining source.update!(amount_cents_remaining: source.amount_cents_remaining - amount) end + # Keep a CE registration's requested↔paid status in step with its payments. + def sync_ce_registration_status + allocatable.sync_payment_status! if allocatable.is_a?(ContinuingEducationRegistration) + end + def reverted? reverted_id.present? end @@ -109,6 +116,29 @@ def validate_event_registration_cost end end + def validate_ce_registration_cost + ce_reg = allocatable + return unless ce_reg.is_a?(ContinuingEducationRegistration) + + cost_cents = ce_reg.amount_cents.to_i + if cost_cents <= 0 + errors.add(:base, "Cannot allocate to a CE registration with no cost.") + return + end + + other_total = ce_reg.allocations.sum(:amount) + other_total -= amount_was if persisted? + + if amount.to_i > 0 + if other_total >= cost_cents + errors.add(:base, "CE registration is already fully paid.") + elsif other_total + amount > cost_cents + remaining = cost_cents - other_total + errors.add(:base, "Cannot allocate more than remaining CE cost. Remaining: #{MoneyFormatter.dollars_from_cents(remaining)}") + end + end + end + def reverted_requires_positive_amount if reverted_id.present? && amount.to_i < 0 errors.add(:reverted_id, "must be on a positive amount allocation") diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb new file mode 100644 index 0000000000..cea7cca80b --- /dev/null +++ b/app/models/continuing_education_registration.rb @@ -0,0 +1,67 @@ +class ContinuingEducationRegistration < ApplicationRecord + has_paper_trail + + belongs_to :event_registration + belongs_to :professional_license + belongs_to :created_by, class_name: "User", optional: true + belongs_to :updated_by, class_name: "User", optional: true + + has_many :allocations, as: :allocatable, dependent: :destroy + has_many :payments, through: :allocations, source: :source, source_type: "Payment" + + # Default amount a registrant owes per CE hour. The training's ce_hours is + # multiplied by this to bill the registration. + HOURLY_RATE_DOLLARS = 25 + + # Fulfillment lifecycle. Plain strings (no enum, per project convention): + # requested → paid (auto, on full payment) → issued (admin), or unawarded. + STATUSES = %w[ requested paid issued unawarded ].freeze + + before_validation :default_hours_from_event, on: :create + before_save :calculate_amount + + validates :status, inclusion: { in: STATUSES } + validates :hours, numericality: { greater_than_or_equal_to: 0 } + validate :license_belongs_to_registrant + + def amount_owed_cents + [ amount_cents - paid_cents, 0 ].max + end + + def paid_cents + if allocations.loaded? + return allocations.select { |a| a.source_type == "Payment" }.sum(&:amount) + end + allocations.where(source_type: "Payment").sum(:amount) + end + + def paid_in_full? + paid_cents >= amount_cents + end + + # Advance requested↔paid to track real payments without clobbering a later + # admin state (issued/unawarded). Called when allocations change. + def sync_payment_status! + return unless status == "requested" || status == "paid" + + target = paid_in_full? ? "paid" : "requested" + update!(status: target) unless status == target + end + + private + + def default_hours_from_event + self.hours = event_registration&.event&.ce_hours if hours.blank? || hours.zero? + end + + def calculate_amount + self.amount_cents = (hours.to_d * rate_cents).round + end + + def license_belongs_to_registrant + return if professional_license.blank? || event_registration.blank? + return if professional_license.person_id == event_registration.registrant_id + + errors.add(:professional_license, "must belong to the registrant") + end +end diff --git a/app/models/event.rb b/app/models/event.rb index a80ac10900..d85e0a4f50 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -192,6 +192,14 @@ def ce_hours_details_label super.presence || "CE hours" end + # What a registrant owes to earn this training's CE hours: the available hours + # times the per-hour rate. Zero when the event grants no CE hours. + def ce_amount_owed_cents + return 0 if ce_hours.blank? + + (ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round + end + # Virtual attributes for date/time inputs (Firefox datetime-local compat) attr_writer :start_date_date, :start_date_time, :end_date_date, :end_date_time, diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index f2b74d1c05..036ca9881d 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -8,6 +8,7 @@ class EventRegistration < ApplicationRecord has_many :notifications, as: :noticeable, dependent: :destroy has_many :organizations, through: :event_registration_organizations has_many :allocations, as: :allocatable + has_many :continuing_education_registrations, dependent: :destroy has_many :scholarships, -> { distinct }, through: :allocations, source: :source, source_type: "Scholarship" has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy diff --git a/app/models/person.rb b/app/models/person.rb index f9f3c3290f..99a4886830 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -9,6 +9,7 @@ class Person < ApplicationRecord has_one :user, inverse_of: :person, dependent: :nullify has_many :affiliations, dependent: :destroy has_many :organizations, through: :affiliations + has_many :professional_licenses, dependent: :destroy has_many :communal_reports, through: :organizations, source: :reports has_many :windows_types, through: :organizations diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb new file mode 100644 index 0000000000..e98ef59fde --- /dev/null +++ b/app/models/professional_license.rb @@ -0,0 +1,33 @@ +class ProfessionalLicense < ApplicationRecord + has_paper_trail + + belongs_to :person + belongs_to :created_by, class_name: "User", optional: true + belongs_to :updated_by, class_name: "User", optional: true + + has_many :continuing_education_registrations, dependent: :destroy + + validates :number, uniqueness: { scope: :person_id }, allow_nil: true + + # Find the person's license for this number, or create it. A blank number + # resolves to the person's single placeholder license (number nil) so a CE + # opt-in without a number on file never spawns duplicate placeholders. + def self.find_or_create_for(person:, number: nil) + find_or_create_by(person: person, number: number.presence) + end + + # Completeness: have we recorded the actual license number yet? + def number_known? + number.present? + end + + # Validity: a license with a past expiration is expired. Unknown when no + # expiration is on file. + def expired? + expires_on.present? && expires_on.past? + end + + def label + [ kind, number ].compact_blank.join(" ").presence || "License (number pending)" + end +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 6d97a12f9c..771d5fea59 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -116,6 +116,8 @@ def google_analytics? :event_details_label, :ce_hours_details, :ce_hours_details_label, + :ce_hours_eligible, + :ce_hours, :autoshow_cost, :autoshow_date, :autoshow_location, diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 4ea491e6c5..e8895ad330 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -326,18 +326,6 @@ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" }, hint: "Shown on an event's scholarship recipients page if designated for a shout-out" %> - -
- <%= f.input :license_type, - label: "License type", - input_html: { class: "w-full" }, - wrapper_html: { class: "w-full sm:w-1/2" } %> - - <%= f.input :license_number, - label: "License number", - input_html: { class: "w-full" }, - wrapper_html: { class: "w-full sm:w-1/2" } %> -
diff --git a/db/migrate/20260625130421_create_professional_licenses.rb b/db/migrate/20260625130421_create_professional_licenses.rb new file mode 100644 index 0000000000..b75d73704c --- /dev/null +++ b/db/migrate/20260625130421_create_professional_licenses.rb @@ -0,0 +1,25 @@ +class CreateProfessionalLicenses < ActiveRecord::Migration[8.1] + def up + create_table :professional_licenses do |t| + t.references :person, null: false, foreign_key: true + t.string :number + t.string :kind + t.string :issuing_state + t.date :expires_on + t.bigint :created_by_id + t.bigint :updated_by_id + t.timestamps + end + add_index :professional_licenses, :created_by_id + add_index :professional_licenses, :updated_by_id + # Enforces one license per (person, number). MySQL treats NULLs as distinct, + # so this guards numbered licenses; the model keeps the single placeholder + # (number-less) license per person via find_or_create. + add_index :professional_licenses, [ :person_id, :number ], unique: true, + name: "index_professional_licenses_on_person_and_number" + end + + def down + drop_table :professional_licenses, if_exists: true + end +end diff --git a/db/migrate/20260625152706_add_ce_hours_fields_to_events.rb b/db/migrate/20260625152706_add_ce_hours_fields_to_events.rb new file mode 100644 index 0000000000..22747a2830 --- /dev/null +++ b/db/migrate/20260625152706_add_ce_hours_fields_to_events.rb @@ -0,0 +1,14 @@ +class AddCeHoursFieldsToEvents < ActiveRecord::Migration[8.1] + def up + # ce_hours_eligible is the explicit "this training grants CE" gate; ce_hours + # is how many (fractional) hours a registrant can earn. Distinct from the + # existing ce_hours_details copy, which is display-only. + add_column :events, :ce_hours_eligible, :boolean, null: false, default: false + add_column :events, :ce_hours, :decimal, precision: 5, scale: 2 + end + + def down + remove_column :events, :ce_hours_eligible, if_exists: true + remove_column :events, :ce_hours, if_exists: true + end +end diff --git a/db/migrate/20260625152707_create_continuing_education_registrations.rb b/db/migrate/20260625152707_create_continuing_education_registrations.rb new file mode 100644 index 0000000000..1847e037ec --- /dev/null +++ b/db/migrate/20260625152707_create_continuing_education_registrations.rb @@ -0,0 +1,23 @@ +class CreateContinuingEducationRegistrations < ActiveRecord::Migration[8.1] + def up + create_table :continuing_education_registrations do |t| + t.references :event_registration, null: false, foreign_key: true + t.references :professional_license, null: false, foreign_key: true + t.decimal :hours, precision: 5, scale: 2, null: false, default: 0 + t.integer :rate_cents, null: false, default: 2500 + t.integer :amount_cents, null: false, default: 0 + t.string :status, null: false, default: "requested" + t.datetime :issued_at + t.datetime :certificate_sent_at + t.bigint :created_by_id + t.bigint :updated_by_id + t.timestamps + end + add_index :continuing_education_registrations, :created_by_id + add_index :continuing_education_registrations, :updated_by_id + end + + def down + drop_table :continuing_education_registrations, if_exists: true + end +end diff --git a/db/migrate/20260625152708_remove_license_fields_from_people.rb b/db/migrate/20260625152708_remove_license_fields_from_people.rb new file mode 100644 index 0000000000..8a61146cfc --- /dev/null +++ b/db/migrate/20260625152708_remove_license_fields_from_people.rb @@ -0,0 +1,13 @@ +class RemoveLicenseFieldsFromPeople < ActiveRecord::Migration[8.1] + # A person's license now lives in professional_licenses. These denormalized + # columns hold no data, so drop them. + def up + remove_column :people, :license_number, if_exists: true + remove_column :people, :license_type, if_exists: true + end + + def down + add_column :people, :license_number, :string unless column_exists?(:people, :license_number) + add_column :people, :license_type, :string unless column_exists?(:people, :license_type) + end +end diff --git a/db/schema.rb b/db/schema.rb index 1c7a7658e1..08ea6cc4bc 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_23_104203) do +ActiveRecord::Schema[8.1].define(version: 2026_06_25_152708) 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 @@ -422,6 +422,25 @@ t.index ["contactable_type", "contactable_id"], name: "index_contact_methods_on_contactable" end + create_table "continuing_education_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "amount_cents", default: 0, null: false + t.datetime "certificate_sent_at" + t.datetime "created_at", null: false + t.bigint "created_by_id" + t.bigint "event_registration_id", null: false + t.decimal "hours", precision: 5, scale: 2, default: "0.0", null: false + t.datetime "issued_at" + t.bigint "professional_license_id", null: false + t.integer "rate_cents", default: 2500, null: false + t.string "status", default: "requested", null: false + t.datetime "updated_at", null: false + t.bigint "updated_by_id" + t.index ["created_by_id"], name: "index_continuing_education_registrations_on_created_by_id" + t.index ["event_registration_id"], name: "idx_on_event_registration_id_41b5ee2e9c" + t.index ["professional_license_id"], name: "idx_on_professional_license_id_54fea68922" + t.index ["updated_by_id"], name: "index_continuing_education_registrations_on_updated_by_id" + end + create_table "discounts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.integer "amount_cents", default: 0, null: false t.datetime "created_at", null: false @@ -518,8 +537,10 @@ t.boolean "autoshow_title", default: true, null: false t.boolean "autoshow_videoconference_label", default: true, null: false t.boolean "autoshow_videoconference_link", default: true, null: false + t.decimal "ce_hours", precision: 5, scale: 2 t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false + t.boolean "ce_hours_eligible", default: false, null: false t.integer "cost_cents" t.datetime "created_at", null: false t.integer "created_by_id" @@ -961,8 +982,6 @@ t.string "instagram_url" t.string "last_name", null: false t.string "legal_first_name" - t.string "license_number" - t.string "license_type" t.string "linked_in_url" t.datetime "mailing_list_consent_at" t.string "mailing_list_consent_source" @@ -1004,6 +1023,22 @@ t.datetime "updated_at", precision: nil, null: false end + create_table "professional_licenses", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "created_by_id" + t.date "expires_on" + t.string "issuing_state" + t.string "kind" + t.string "number" + t.bigint "person_id", null: false + 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"], name: "index_professional_licenses_on_person_id" + t.index ["updated_by_id"], name: "index_professional_licenses_on_updated_by_id" + end + create_table "quotable_item_quotes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.integer "legacy_id" @@ -1653,6 +1688,8 @@ add_foreign_key "community_news", "users", column: "updated_by_id" add_foreign_key "community_news", "windows_types" add_foreign_key "contact_methods", "addresses" + add_foreign_key "continuing_education_registrations", "event_registrations" + add_foreign_key "continuing_education_registrations", "professional_licenses" add_foreign_key "event_forms", "events" add_foreign_key "event_forms", "forms" add_foreign_key "event_registration_checklist_completions", "event_registrations" @@ -1692,6 +1729,7 @@ add_foreign_key "payments", "people" add_foreign_key "people", "users", column: "created_by_id" add_foreign_key "people", "users", column: "updated_by_id" + add_foreign_key "professional_licenses", "people" add_foreign_key "quotable_item_quotes", "quotes" add_foreign_key "quotes", "workshops" add_foreign_key "registration_ticket_callouts", "events" diff --git a/spec/factories/continuing_education_registrations.rb b/spec/factories/continuing_education_registrations.rb new file mode 100644 index 0000000000..8304618c1a --- /dev/null +++ b/spec/factories/continuing_education_registrations.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :continuing_education_registration do + association :event_registration + hours { 6 } + professional_license { association(:professional_license, person: event_registration.registrant) } + end +end diff --git a/spec/factories/professional_licenses.rb b/spec/factories/professional_licenses.rb new file mode 100644 index 0000000000..bcd3028e76 --- /dev/null +++ b/spec/factories/professional_licenses.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :professional_license do + association :person + sequence(:number) { |n| "LIC-#{n}" } + kind { "LMFT" } + issuing_state { "CA" } + + trait :placeholder do + number { nil } + end + end +end diff --git a/spec/models/allocation_spec.rb b/spec/models/allocation_spec.rb index 429d78145c..e534a46a97 100644 --- a/spec/models/allocation_spec.rb +++ b/spec/models/allocation_spec.rb @@ -2,6 +2,32 @@ RSpec.describe Allocation, type: :model do describe "validations" do + describe "validate_ce_registration_cost" do + let(:ce_reg) { create(:continuing_education_registration, hours: 4) } # amount_cents 10_000 + let(:payment) { create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) } + + it "is valid when amount is within the CE cost" do + allocation = build(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + expect(allocation).to be_valid + end + + it "is invalid when allocating more than the remaining CE cost" do + create(:allocation, source: payment, allocatable: ce_reg, amount: 8_000) + second_payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + allocation = build(:allocation, source: second_payment, allocatable: ce_reg, amount: 5_000) + expect(allocation).not_to be_valid + expect(allocation.errors[:base].join).to include("Cannot allocate more than remaining CE cost") + end + + it "is invalid when the CE registration is already fully paid" do + create(:allocation, source: payment, allocatable: ce_reg, amount: 10_000) + second_payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + allocation = build(:allocation, source: second_payment, allocatable: ce_reg, amount: 1_000) + expect(allocation).not_to be_valid + expect(allocation.errors[:base]).to include("CE registration is already fully paid.") + end + end + describe "validate_event_registration_cost" do let(:event) { create(:event, cost_cents: 10_000) } let(:registration) { create(:event_registration, event:) } diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb new file mode 100644 index 0000000000..ccb69b868f --- /dev/null +++ b/spec/models/continuing_education_registration_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe ContinuingEducationRegistration, type: :model do + describe "validations" do + it "requires a known status" do + ce_reg = build(:continuing_education_registration, status: "bogus") + expect(ce_reg).not_to be_valid + expect(ce_reg.errors[:status]).to be_present + end + + it "rejects a license that belongs to someone other than the registrant" do + registration = create(:event_registration) + other_license = create(:professional_license) + + ce_reg = build(:continuing_education_registration, + event_registration: registration, professional_license: other_license) + + expect(ce_reg).not_to be_valid + expect(ce_reg.errors[:professional_license]).to include("must belong to the registrant") + end + end + + describe "hours and amount" do + it "defaults hours from the event's available CE hours on create" do + event = create(:event, ce_hours: 8) + registration = create(:event_registration, event: event) + + ce_reg = create(:continuing_education_registration, + event_registration: registration, hours: nil, + professional_license: create(:professional_license, person: registration.registrant)) + + expect(ce_reg.hours).to eq(8) + end + + it "recomputes amount_cents from editable (fractional) hours on save" do + ce_reg = create(:continuing_education_registration, hours: 6) + expect(ce_reg.amount_cents).to eq(6 * 2500) + + ce_reg.update!(hours: 1.5) + expect(ce_reg.amount_cents).to eq(3750) + end + end + + describe "payment status" do + it "auto-advances requested → paid once fully paid" do + ce_reg = create(:continuing_education_registration, hours: 4) + expect(ce_reg.status).to eq("requested") + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 10_000) + + expect(ce_reg.reload.status).to eq("paid") + expect(ce_reg).to be_paid_in_full + end + + it "does not clobber a later issued status" do + ce_reg = create(:continuing_education_registration, hours: 4, status: "issued") + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 10_000) + + expect(ce_reg.reload.status).to eq("issued") + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 5f5b40cfc2..8b176fad80 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -315,4 +315,15 @@ expect(results).not_to include(art_event, music_event) end end + + describe "#ce_amount_owed_cents" do + it "multiplies the available CE hours by the hourly rate" do + event = build(:event, ce_hours: 6) + expect(event.ce_amount_owed_cents).to eq(6 * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) + end + + it "is zero when the event grants no CE hours" do + expect(build(:event, ce_hours: nil).ce_amount_owed_cents).to eq(0) + end + end end diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb new file mode 100644 index 0000000000..9ed1565e62 --- /dev/null +++ b/spec/models/professional_license_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe ProfessionalLicense, type: :model do + let(:person) { create(:person) } + + describe ".find_or_create_for" do + it "creates and then reuses a license for a given number" do + first = described_class.find_or_create_for(person: person, number: "ABC-1") + second = described_class.find_or_create_for(person: person, number: "ABC-1") + + expect(first).to eq(second) + expect(person.professional_licenses.count).to eq(1) + end + + it "creates a separate license for a different number" do + described_class.find_or_create_for(person: person, number: "ABC-1") + described_class.find_or_create_for(person: person, number: "ABC-2") + + expect(person.professional_licenses.count).to eq(2) + end + + it "reuses a single placeholder when no number is given" do + first = described_class.find_or_create_for(person: person, number: "") + second = described_class.find_or_create_for(person: person, number: nil) + + expect(first).to eq(second) + expect(first.number).to be_nil + expect(person.professional_licenses.count).to eq(1) + end + end + + describe "#number_known?" do + it "is true only when a number is present" do + expect(build(:professional_license, number: "X")).to be_number_known + expect(build(:professional_license, :placeholder)).not_to be_number_known + end + end + + describe "#expired?" do + it "is true only for a past expiration on file" do + expect(build(:professional_license, expires_on: Date.current - 1)).to be_expired + expect(build(:professional_license, expires_on: Date.current + 1)).not_to be_expired + expect(build(:professional_license, expires_on: nil)).not_to be_expired + end + 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") + + expect(dup).not_to be_valid + end + end +end From 4db30ed8a84f20b5fa30b1e4028f5f8949e46545 Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 25 Jun 2026 16:19:44 -0400 Subject: [PATCH 02/13] Rename ProfessionalLicense#label to #name Co-Authored-By: Claude Opus 4.8 --- app/models/professional_license.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index e98ef59fde..0f07ff774c 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -27,7 +27,7 @@ def expired? expires_on.present? && expires_on.past? end - def label + def name [ kind, number ].compact_blank.join(" ").presence || "License (number pending)" end end From f76222729f2507c08eca71e42765a477bb01e166 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 26 Jun 2026 13:08:21 -0400 Subject: [PATCH 03/13] Add per-event ce_hour_cost override for CE hourly rate The CE per-hour price was hard-coded to the HOURLY_RATE_DOLLARS constant, so every event billed the same rate. Add a nullable ce_hour_cost_cents column (stored in cents, mirroring cost_cents) with a ce_hour_cost dollars virtual attribute for the form. A nil means "use the standard rate": ce_hour_cost_cents falls back to the constant, so new and existing events show the default until an admin sets a per-event override. ce_amount_owed_cents now bills off it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/event.rb | 25 ++++++++++++++++++- app/policies/event_policy.rb | 1 + app/views/events/_form.html.erb | 8 ++++++ ...260626162253_add_ce_hour_cost_to_events.rb | 12 +++++++++ db/schema.rb | 3 ++- spec/models/event_spec.rb | 25 +++++++++++++++++++ 6 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260626162253_add_ce_hour_cost_to_events.rb diff --git a/app/models/event.rb b/app/models/event.rb index d85e0a4f50..e728af1031 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -192,12 +192,19 @@ def ce_hours_details_label super.presence || "CE hours" end + # Per-hour CE price in cents. Falls back to the standard rate when no per-event + # override is set, so new and existing events both bill the default until an + # admin changes it. + def ce_hour_cost_cents + super || ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100 + end + # What a registrant owes to earn this training's CE hours: the available hours # times the per-hour rate. Zero when the event grants no CE hours. def ce_amount_owed_cents return 0 if ce_hours.blank? - (ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round + (ce_hours * ce_hour_cost_cents).round end # Virtual attributes for date/time inputs (Firefox datetime-local compat) @@ -244,6 +251,22 @@ def cost=(dollar_amount) end end + # Virtual attribute for the CE hourly cost in dollars (converts to/from + # ce_hour_cost_cents), mirroring #cost. Reads back the default rate when unset + # so the event form shows the standard rate for new and existing events. + def ce_hour_cost + ce_hour_cost_cents / 100.0 + end + + def ce_hour_cost=(dollar_amount) + if dollar_amount.blank? + self.ce_hour_cost_cents = nil + else + dollar_amount = dollar_amount.to_s.gsub(/[^\d.]/, "").to_f + self.ce_hour_cost_cents = (dollar_amount * 100).round + end + end + def attachable_content_type "application/vnd.active_record.event" end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 771d5fea59..49d78d0aa7 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -118,6 +118,7 @@ def google_analytics? :ce_hours_details_label, :ce_hours_eligible, :ce_hours, + :ce_hour_cost, :autoshow_cost, :autoshow_date, :autoshow_location, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index ce29656eb2..de64a2293a 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -471,6 +471,14 @@ title_placeholder: "CE hours", content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %> +
+ +
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, diff --git a/db/migrate/20260626162253_add_ce_hour_cost_to_events.rb b/db/migrate/20260626162253_add_ce_hour_cost_to_events.rb new file mode 100644 index 0000000000..ec70c92c89 --- /dev/null +++ b/db/migrate/20260626162253_add_ce_hour_cost_to_events.rb @@ -0,0 +1,12 @@ +class AddCeHourCostToEvents < ActiveRecord::Migration[8.1] + # Per-event override of the CE per-hour price, in cents (mirrors cost_cents). + # Left nullable with no DB default so a nil means "use the standard rate" — + # Event#ce_hour_cost_cents falls back to the default hourly rate. + def up + add_column :events, :ce_hour_cost_cents, :integer unless column_exists?(:events, :ce_hour_cost_cents) + end + + def down + remove_column :events, :ce_hour_cost_cents, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 08ea6cc4bc..313ba2761e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_25_152708) do +ActiveRecord::Schema[8.1].define(version: 2026_06_26_162253) 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 @@ -537,6 +537,7 @@ t.boolean "autoshow_title", default: true, null: false t.boolean "autoshow_videoconference_label", default: true, null: false t.boolean "autoshow_videoconference_link", default: true, null: false + t.integer "ce_hour_cost_cents" t.decimal "ce_hours", precision: 5, scale: 2 t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 8b176fad80..6134a4447d 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -316,12 +316,37 @@ end end + describe "#ce_hour_cost_cents" do + it "falls back to the standard rate when no per-event override is set" do + expect(build(:event, ce_hour_cost_cents: nil).ce_hour_cost_cents).to eq(ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) + end + + it "uses the per-event override when set" do + expect(build(:event, ce_hour_cost_cents: 4000).ce_hour_cost_cents).to eq(4000) + end + end + + describe "#ce_hour_cost (dollars)" do + it "reads back the standard rate in dollars when unset" do + expect(build(:event, ce_hour_cost_cents: nil).ce_hour_cost).to eq(ContinuingEducationRegistration::HOURLY_RATE_DOLLARS) + end + + it "converts a dollar amount to cents on assignment" do + expect(build(:event, ce_hour_cost: 40).ce_hour_cost_cents).to eq(4000) + end + end + describe "#ce_amount_owed_cents" do it "multiplies the available CE hours by the hourly rate" do event = build(:event, ce_hours: 6) expect(event.ce_amount_owed_cents).to eq(6 * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) end + it "uses the per-event hourly cost override" do + event = build(:event, ce_hours: 6, ce_hour_cost: 40) + expect(event.ce_amount_owed_cents).to eq(6 * 40 * 100) + end + it "is zero when the event grants no CE hours" do expect(build(:event, ce_hours: nil).ce_amount_owed_cents).to eq(0) end From 20adfcc20c87f2ca3867154146ce200ae2b3030f Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 26 Jun 2026 13:08:45 -0400 Subject: [PATCH 04/13] CE registration snapshots cost_cents instead of carrying a rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-hour CE rate now lives on Event#ce_hour_cost_cents, so a CE registration no longer needs its own rate_cents — it just snapshots the total cost it bills. Rename amount_cents → cost_cents and drop rate_cents. The cost is priced from the event's per-hour rate on create and re-priced only when hours change, so editing the event rate later never silently re-bills a registration that's already been paid. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/allocation.rb | 2 +- .../continuing_education_registration.rb | 19 ++++++++++++++----- ...5811_rename_ce_amount_to_cost_drop_rate.rb | 14 ++++++++++++++ db/schema.rb | 5 ++--- spec/models/allocation_spec.rb | 2 +- .../continuing_education_registration_spec.rb | 17 ++++++++++++++--- 6 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260626165811_rename_ce_amount_to_cost_drop_rate.rb diff --git a/app/models/allocation.rb b/app/models/allocation.rb index e917b62a48..3062ab267f 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -120,7 +120,7 @@ def validate_ce_registration_cost ce_reg = allocatable return unless ce_reg.is_a?(ContinuingEducationRegistration) - cost_cents = ce_reg.amount_cents.to_i + cost_cents = ce_reg.cost_cents.to_i if cost_cents <= 0 errors.add(:base, "Cannot allocate to a CE registration with no cost.") return diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index cea7cca80b..0538c81096 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -18,14 +18,14 @@ class ContinuingEducationRegistration < ApplicationRecord STATUSES = %w[ requested paid issued unawarded ].freeze before_validation :default_hours_from_event, on: :create - before_save :calculate_amount + before_save :calculate_cost, if: -> { new_record? || hours_changed? } validates :status, inclusion: { in: STATUSES } validates :hours, numericality: { greater_than_or_equal_to: 0 } validate :license_belongs_to_registrant def amount_owed_cents - [ amount_cents - paid_cents, 0 ].max + [ cost_cents - paid_cents, 0 ].max end def paid_cents @@ -36,7 +36,7 @@ def paid_cents end def paid_in_full? - paid_cents >= amount_cents + paid_cents >= cost_cents end # Advance requested↔paid to track real payments without clobbering a later @@ -54,8 +54,17 @@ def default_hours_from_event self.hours = event_registration&.event&.ce_hours if hours.blank? || hours.zero? end - def calculate_amount - self.amount_cents = (hours.to_d * rate_cents).round + # Snapshot the total cost from the event's per-hour CE rate. Recomputed only on + # create or when hours change, so an admin editing the event rate later never + # silently re-prices a registration that's already been billed or paid. + def calculate_cost + self.cost_cents = (hours.to_d * ce_rate_cents).round + end + + # Per-hour CE price (cents) from the event, falling back to the default rate + # when there's no event yet (e.g. an in-memory build). + def ce_rate_cents + event_registration&.event&.ce_hour_cost_cents || ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100 end def license_belongs_to_registrant diff --git a/db/migrate/20260626165811_rename_ce_amount_to_cost_drop_rate.rb b/db/migrate/20260626165811_rename_ce_amount_to_cost_drop_rate.rb new file mode 100644 index 0000000000..37bd8b0ddd --- /dev/null +++ b/db/migrate/20260626165811_rename_ce_amount_to_cost_drop_rate.rb @@ -0,0 +1,14 @@ +class RenameCeAmountToCostDropRate < ActiveRecord::Migration[8.1] + # The per-hour CE rate now lives on Event#ce_hour_cost_cents, so a CE + # registration no longer needs its own rate column — it just snapshots the + # total cost it bills. Rename amount_cents → cost_cents and drop rate_cents. + def up + rename_column :continuing_education_registrations, :amount_cents, :cost_cents if column_exists?(:continuing_education_registrations, :amount_cents) + remove_column :continuing_education_registrations, :rate_cents, if_exists: true + end + + def down + add_column :continuing_education_registrations, :rate_cents, :integer, null: false, default: 2500 unless column_exists?(:continuing_education_registrations, :rate_cents) + rename_column :continuing_education_registrations, :cost_cents, :amount_cents if column_exists?(:continuing_education_registrations, :cost_cents) + end +end diff --git a/db/schema.rb b/db/schema.rb index 313ba2761e..065accd764 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_26_162253) do +ActiveRecord::Schema[8.1].define(version: 2026_06_26_165811) 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 @@ -423,15 +423,14 @@ end create_table "continuing_education_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.integer "amount_cents", default: 0, null: false t.datetime "certificate_sent_at" + t.integer "cost_cents", default: 0, null: false t.datetime "created_at", null: false t.bigint "created_by_id" t.bigint "event_registration_id", null: false t.decimal "hours", precision: 5, scale: 2, default: "0.0", null: false t.datetime "issued_at" t.bigint "professional_license_id", null: false - t.integer "rate_cents", default: 2500, null: false t.string "status", default: "requested", null: false t.datetime "updated_at", null: false t.bigint "updated_by_id" diff --git a/spec/models/allocation_spec.rb b/spec/models/allocation_spec.rb index e534a46a97..cf334e1c50 100644 --- a/spec/models/allocation_spec.rb +++ b/spec/models/allocation_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Allocation, type: :model do describe "validations" do describe "validate_ce_registration_cost" do - let(:ce_reg) { create(:continuing_education_registration, hours: 4) } # amount_cents 10_000 + let(:ce_reg) { create(:continuing_education_registration, hours: 4) } # cost_cents 10_000 let(:payment) { create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) } it "is valid when amount is within the CE cost" do diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index ccb69b868f..e4ad933910 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -32,12 +32,23 @@ expect(ce_reg.hours).to eq(8) end - it "recomputes amount_cents from editable (fractional) hours on save" do + it "recomputes cost_cents from editable (fractional) hours on save" do ce_reg = create(:continuing_education_registration, hours: 6) - expect(ce_reg.amount_cents).to eq(6 * 2500) + expect(ce_reg.cost_cents).to eq(6 * 2500) ce_reg.update!(hours: 1.5) - expect(ce_reg.amount_cents).to eq(3750) + expect(ce_reg.cost_cents).to eq(3750) + end + + it "prices cost_cents from the event's per-hour CE cost override" do + event = create(:event, ce_hour_cost_cents: 4000) + registration = create(:event_registration, event: event) + + ce_reg = create(:continuing_education_registration, + event_registration: registration, hours: 6, + professional_license: create(:professional_license, person: registration.registrant)) + + expect(ce_reg.cost_cents).to eq(6 * 4000) end end From 6e6aabbaae4dc5a042391454e801a48a6565844e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 21:08:35 -0400 Subject: [PATCH 05/13] Store CE hours/cost on the event instead of deriving totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot away from a per-hour rate that gets multiplied out. Event now carries ce_hours_available (decimal hours offered) and ce_hours_cost_cents (the total CE cost, with a ce_hours_cost dollars virtual attribute like #cost). A CE registration snapshots both — hours and cost_cents default from the event on create and are otherwise stored as-is — so nothing recomputes a total. Drops the HOURLY_RATE_DOLLARS constant, the per-hour rate column, and Event#ce_amount_owed_cents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../continuing_education_registration.rb | 29 +++++----------- app/models/event.rb | 31 +++++------------ app/policies/event_policy.rb | 4 +-- app/views/events/_form.html.erb | 9 +++-- .../20260629010238_rename_event_ce_fields.rb | 14 ++++++++ db/schema.rb | 6 ++-- .../continuing_education_registrations.rb | 1 + spec/models/allocation_spec.rb | 2 +- .../continuing_education_registration_spec.rb | 29 +++++++++------- spec/models/event_spec.rb | 34 +++++-------------- 10 files changed, 68 insertions(+), 91 deletions(-) create mode 100644 db/migrate/20260629010238_rename_event_ce_fields.rb diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 0538c81096..857bf4ff63 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -9,19 +9,15 @@ class ContinuingEducationRegistration < ApplicationRecord has_many :allocations, as: :allocatable, dependent: :destroy has_many :payments, through: :allocations, source: :source, source_type: "Payment" - # Default amount a registrant owes per CE hour. The training's ce_hours is - # multiplied by this to bill the registration. - HOURLY_RATE_DOLLARS = 25 - # Fulfillment lifecycle. Plain strings (no enum, per project convention): # requested → paid (auto, on full payment) → issued (admin), or unawarded. STATUSES = %w[ requested paid issued unawarded ].freeze - before_validation :default_hours_from_event, on: :create - before_save :calculate_cost, if: -> { new_record? || hours_changed? } + before_validation :default_from_event, on: :create validates :status, inclusion: { in: STATUSES } validates :hours, numericality: { greater_than_or_equal_to: 0 } + validates :cost_cents, numericality: { greater_than_or_equal_to: 0 } validate :license_belongs_to_registrant def amount_owed_cents @@ -50,21 +46,12 @@ def sync_payment_status! private - def default_hours_from_event - self.hours = event_registration&.event&.ce_hours if hours.blank? || hours.zero? - end - - # Snapshot the total cost from the event's per-hour CE rate. Recomputed only on - # create or when hours change, so an admin editing the event rate later never - # silently re-prices a registration that's already been billed or paid. - def calculate_cost - self.cost_cents = (hours.to_d * ce_rate_cents).round - end - - # Per-hour CE price (cents) from the event, falling back to the default rate - # when there's no event yet (e.g. an in-memory build). - def ce_rate_cents - event_registration&.event&.ce_hour_cost_cents || ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100 + # Snapshot the hours offered and total cost from the event when they aren't set + # explicitly. Both are plain stored values — no per-hour rate is multiplied out. + def default_from_event + event = event_registration&.event + self.hours = event.ce_hours_available if event&.ce_hours_available && (hours.blank? || hours.zero?) + self.cost_cents = event.ce_hours_cost_cents if event&.ce_hours_cost_cents && (cost_cents.blank? || cost_cents.zero?) end def license_belongs_to_registrant diff --git a/app/models/event.rb b/app/models/event.rb index e728af1031..f2ae513c35 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -192,21 +192,6 @@ def ce_hours_details_label super.presence || "CE hours" end - # Per-hour CE price in cents. Falls back to the standard rate when no per-event - # override is set, so new and existing events both bill the default until an - # admin changes it. - def ce_hour_cost_cents - super || ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100 - end - - # What a registrant owes to earn this training's CE hours: the available hours - # times the per-hour rate. Zero when the event grants no CE hours. - def ce_amount_owed_cents - return 0 if ce_hours.blank? - - (ce_hours * ce_hour_cost_cents).round - end - # Virtual attributes for date/time inputs (Firefox datetime-local compat) attr_writer :start_date_date, :start_date_time, :end_date_date, :end_date_time, @@ -251,19 +236,19 @@ def cost=(dollar_amount) end end - # Virtual attribute for the CE hourly cost in dollars (converts to/from - # ce_hour_cost_cents), mirroring #cost. Reads back the default rate when unset - # so the event form shows the standard rate for new and existing events. - def ce_hour_cost - ce_hour_cost_cents / 100.0 + # Virtual attribute for the total CE cost in dollars (converts to/from + # ce_hours_cost_cents), mirroring #cost. + def ce_hours_cost + return nil if ce_hours_cost_cents.nil? + ce_hours_cost_cents / 100.0 end - def ce_hour_cost=(dollar_amount) + def ce_hours_cost=(dollar_amount) if dollar_amount.blank? - self.ce_hour_cost_cents = nil + self.ce_hours_cost_cents = nil else dollar_amount = dollar_amount.to_s.gsub(/[^\d.]/, "").to_f - self.ce_hour_cost_cents = (dollar_amount * 100).round + self.ce_hours_cost_cents = (dollar_amount * 100).round end end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 49d78d0aa7..ec132c26ab 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -117,8 +117,8 @@ def google_analytics? :ce_hours_details, :ce_hours_details_label, :ce_hours_eligible, - :ce_hours, - :ce_hour_cost, + :ce_hours_available, + :ce_hours_cost, :autoshow_cost, :autoshow_date, :autoshow_location, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index de64a2293a..3bcdb7db91 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -473,9 +473,14 @@ content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %>
+
diff --git a/db/migrate/20260629010238_rename_event_ce_fields.rb b/db/migrate/20260629010238_rename_event_ce_fields.rb new file mode 100644 index 0000000000..5bb9dacf21 --- /dev/null +++ b/db/migrate/20260629010238_rename_event_ce_fields.rb @@ -0,0 +1,14 @@ +class RenameEventCeFields < ActiveRecord::Migration[8.1] + # Pivot the event CE fields onto stored values rather than a per-hour rate that + # gets multiplied out: ce_hours -> ce_hours_available (the decimal hours + # offered) and ce_hour_cost_cents -> ce_hours_cost_cents (the total CE cost). + def up + rename_column :events, :ce_hours, :ce_hours_available if column_exists?(:events, :ce_hours) + rename_column :events, :ce_hour_cost_cents, :ce_hours_cost_cents if column_exists?(:events, :ce_hour_cost_cents) + end + + def down + rename_column :events, :ce_hours_available, :ce_hours if column_exists?(:events, :ce_hours_available) + rename_column :events, :ce_hours_cost_cents, :ce_hour_cost_cents if column_exists?(:events, :ce_hours_cost_cents) + end +end diff --git a/db/schema.rb b/db/schema.rb index 065accd764..e2336fe06b 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_26_165811) do +ActiveRecord::Schema[8.1].define(version: 2026_06_29_010238) 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 @@ -536,8 +536,8 @@ t.boolean "autoshow_title", default: true, null: false t.boolean "autoshow_videoconference_label", default: true, null: false t.boolean "autoshow_videoconference_link", default: true, null: false - t.integer "ce_hour_cost_cents" - t.decimal "ce_hours", precision: 5, scale: 2 + t.decimal "ce_hours_available", precision: 5, scale: 2 + t.integer "ce_hours_cost_cents" t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false t.boolean "ce_hours_eligible", default: false, null: false diff --git a/spec/factories/continuing_education_registrations.rb b/spec/factories/continuing_education_registrations.rb index 8304618c1a..f8bb585f40 100644 --- a/spec/factories/continuing_education_registrations.rb +++ b/spec/factories/continuing_education_registrations.rb @@ -2,6 +2,7 @@ factory :continuing_education_registration do association :event_registration hours { 6 } + cost_cents { 15_000 } professional_license { association(:professional_license, person: event_registration.registrant) } end end diff --git a/spec/models/allocation_spec.rb b/spec/models/allocation_spec.rb index cf334e1c50..298ff09fc5 100644 --- a/spec/models/allocation_spec.rb +++ b/spec/models/allocation_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Allocation, type: :model do describe "validations" do describe "validate_ce_registration_cost" do - let(:ce_reg) { create(:continuing_education_registration, hours: 4) } # cost_cents 10_000 + let(:ce_reg) { create(:continuing_education_registration, cost_cents: 10_000) } let(:payment) { create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) } it "is valid when amount is within the CE cost" do diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index e4ad933910..1a9cedbf4a 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -20,9 +20,9 @@ end end - describe "hours and amount" do + describe "hours and cost defaults from the event" do it "defaults hours from the event's available CE hours on create" do - event = create(:event, ce_hours: 8) + event = create(:event, ce_hours_available: 8) registration = create(:event_registration, event: event) ce_reg = create(:continuing_education_registration, @@ -32,29 +32,32 @@ expect(ce_reg.hours).to eq(8) end - it "recomputes cost_cents from editable (fractional) hours on save" do - ce_reg = create(:continuing_education_registration, hours: 6) - expect(ce_reg.cost_cents).to eq(6 * 2500) + it "defaults cost_cents from the event's total CE cost on create" do + event = create(:event, ce_hours_cost_cents: 12_000) + registration = create(:event_registration, event: event) + + ce_reg = create(:continuing_education_registration, + event_registration: registration, cost_cents: nil, + professional_license: create(:professional_license, person: registration.registrant)) - ce_reg.update!(hours: 1.5) - expect(ce_reg.cost_cents).to eq(3750) + expect(ce_reg.cost_cents).to eq(12_000) end - it "prices cost_cents from the event's per-hour CE cost override" do - event = create(:event, ce_hour_cost_cents: 4000) + it "keeps an explicitly provided cost rather than the event default" do + event = create(:event, ce_hours_cost_cents: 12_000) registration = create(:event_registration, event: event) ce_reg = create(:continuing_education_registration, - event_registration: registration, hours: 6, + event_registration: registration, cost_cents: 5_000, professional_license: create(:professional_license, person: registration.registrant)) - expect(ce_reg.cost_cents).to eq(6 * 4000) + expect(ce_reg.cost_cents).to eq(5_000) end end describe "payment status" do it "auto-advances requested → paid once fully paid" do - ce_reg = create(:continuing_education_registration, hours: 4) + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) expect(ce_reg.status).to eq("requested") payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) @@ -65,7 +68,7 @@ end it "does not clobber a later issued status" do - ce_reg = create(:continuing_education_registration, hours: 4, status: "issued") + ce_reg = create(:continuing_education_registration, cost_cents: 10_000, status: "issued") payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) create(:allocation, source: payment, allocatable: ce_reg, amount: 10_000) diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 6134a4447d..42adee78a2 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -316,39 +316,21 @@ end end - describe "#ce_hour_cost_cents" do - it "falls back to the standard rate when no per-event override is set" do - expect(build(:event, ce_hour_cost_cents: nil).ce_hour_cost_cents).to eq(ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) + describe "#ce_hours_cost (dollars)" do + it "is nil when no cost is set" do + expect(build(:event, ce_hours_cost_cents: nil).ce_hours_cost).to be_nil end - it "uses the per-event override when set" do - expect(build(:event, ce_hour_cost_cents: 4000).ce_hour_cost_cents).to eq(4000) - end - end - - describe "#ce_hour_cost (dollars)" do - it "reads back the standard rate in dollars when unset" do - expect(build(:event, ce_hour_cost_cents: nil).ce_hour_cost).to eq(ContinuingEducationRegistration::HOURLY_RATE_DOLLARS) + it "reads the stored cost back in dollars" do + expect(build(:event, ce_hours_cost_cents: 15_000).ce_hours_cost).to eq(150) end it "converts a dollar amount to cents on assignment" do - expect(build(:event, ce_hour_cost: 40).ce_hour_cost_cents).to eq(4000) - end - end - - describe "#ce_amount_owed_cents" do - it "multiplies the available CE hours by the hourly rate" do - event = build(:event, ce_hours: 6) - expect(event.ce_amount_owed_cents).to eq(6 * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100) - end - - it "uses the per-event hourly cost override" do - event = build(:event, ce_hours: 6, ce_hour_cost: 40) - expect(event.ce_amount_owed_cents).to eq(6 * 40 * 100) + expect(build(:event, ce_hours_cost: 150).ce_hours_cost_cents).to eq(15_000) end - it "is zero when the event grants no CE hours" do - expect(build(:event, ce_hours: nil).ce_amount_owed_cents).to eq(0) + it "clears the cents when assigned blank" do + expect(build(:event, ce_hours_cost: "").ce_hours_cost_cents).to be_nil end end end From 1458a76f3e9608aab32b17f6202d74f214783bbc Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 21:10:52 -0400 Subject: [PATCH 06/13] Align CE registration payment methods with EventRegistration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CE registration is an allocatable, like EventRegistration, but it had invented its own payment helpers (amount_owed_cents, paid_cents) and counted only Payment allocations toward paid_in_full? — so a discount would block further allocation yet never mark it covered. Mirror EventRegistration's interface instead: allocations_sum, payments_sum, remaining_cost, paid_in_full? (all allocations count as coverage), and partially_paid?. The over-allocation guard now reads allocations_sum too. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/allocation.rb | 2 +- .../continuing_education_registration.rb | 26 +++++++++++++------ .../continuing_education_registration_spec.rb | 21 +++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/models/allocation.rb b/app/models/allocation.rb index 3062ab267f..f709b58c20 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -126,7 +126,7 @@ def validate_ce_registration_cost return end - other_total = ce_reg.allocations.sum(:amount) + other_total = ce_reg.allocations_sum other_total -= amount_was if persisted? if amount.to_i > 0 diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 857bf4ff63..a8cce6a057 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -20,19 +20,29 @@ class ContinuingEducationRegistration < ApplicationRecord validates :cost_cents, numericality: { greater_than_or_equal_to: 0 } validate :license_belongs_to_registrant - def amount_owed_cents - [ cost_cents - paid_cents, 0 ].max + # Allocatable payment interface — mirrors EventRegistration so a CE registration + # behaves the same way wherever allocations are summed. "Covered" counts every + # allocation (payments, discounts, scholarships); payments_sum is cash only. + def allocations_sum + return allocations.to_a.sum(&:amount) if allocations.loaded? + allocations.sum(:amount) end - def paid_cents - if allocations.loaded? - return allocations.select { |a| a.source_type == "Payment" }.sum(&:amount) - end - allocations.where(source_type: "Payment").sum(:amount) + def payments_sum + return allocations.to_a.select { |a| a.source_type == Payment.polymorphic_name }.sum(&:amount) if allocations.loaded? + allocations.where(source_type: Payment.polymorphic_name).sum(:amount) + end + + def remaining_cost + [ cost_cents - allocations_sum, 0 ].max end def paid_in_full? - paid_cents >= cost_cents + allocations_sum >= cost_cents.to_i + end + + def partially_paid? + !paid_in_full? && payments_sum.to_i.positive? end # Advance requested↔paid to track real payments without clobbering a later diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index 1a9cedbf4a..2a2d3a120c 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -75,4 +75,25 @@ expect(ce_reg.reload.status).to eq("issued") end end + + describe "allocatable payment interface" do + it "counts a discount as coverage toward paid_in_full?, like an event registration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + create(:allocation, source: create(:discount, amount_cents: 10_000), allocatable: ce_reg, amount: 10_000) + + expect(ce_reg).to be_paid_in_full + expect(ce_reg.remaining_cost).to eq(0) + expect(ce_reg.payments_sum).to eq(0) + end + + it "remaining_cost subtracts all allocations and payments_sum counts only cash" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 6_000, amount_cents_remaining: 6_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 6_000) + + expect(ce_reg.remaining_cost).to eq(4_000) + expect(ce_reg.payments_sum).to eq(6_000) + expect(ce_reg).to be_partially_paid + end + end end From 652f70ee37ef4914b8d382b1fd5a0c3eaa23be13 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 21:22:55 -0400 Subject: [PATCH 07/13] Rename CE status "unawarded" to "not_issued" Clearer admin-facing wording for the terminal non-issuance state. Co-Authored-By: Claude Opus 4.8 --- app/models/continuing_education_registration.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index a8cce6a057..72f2242d12 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -10,8 +10,8 @@ class ContinuingEducationRegistration < ApplicationRecord has_many :payments, through: :allocations, source: :source, source_type: "Payment" # Fulfillment lifecycle. Plain strings (no enum, per project convention): - # requested → paid (auto, on full payment) → issued (admin), or unawarded. - STATUSES = %w[ requested paid issued unawarded ].freeze + # requested → paid (auto, on full payment) → issued (admin), or not_issued. + STATUSES = %w[ requested paid issued not_issued ].freeze before_validation :default_from_event, on: :create @@ -46,7 +46,7 @@ def partially_paid? end # Advance requested↔paid to track real payments without clobbering a later - # admin state (issued/unawarded). Called when allocations change. + # admin state (issued/not_issued). Called when allocations change. def sync_payment_status! return unless status == "requested" || status == "paid" From 776f0f22b3c02763e234607ca8b680e668549d2d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 21:26:12 -0400 Subject: [PATCH 08/13] Add CE registration #paid? to mirror EventRegistration Polymorphic allocatable callers (rosters, payment partials) message allocatables with #paid?; CE only exposed #paid_in_full?, so a CE registration would raise NoMethodError. Alias it like EventRegistration. Co-Authored-By: Claude Opus 4.8 --- app/models/continuing_education_registration.rb | 6 ++++++ spec/models/continuing_education_registration_spec.rb | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 72f2242d12..bf97faae92 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -41,6 +41,12 @@ def paid_in_full? allocations_sum >= cost_cents.to_i end + # Public-facing payment predicate, mirroring EventRegistration#paid? so a CE + # registration answers the same message wherever allocatables are treated alike. + def paid? + paid_in_full? + end + def partially_paid? !paid_in_full? && payments_sum.to_i.positive? end diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index 2a2d3a120c..45b21b61ba 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -95,5 +95,15 @@ expect(ce_reg.payments_sum).to eq(6_000) expect(ce_reg).to be_partially_paid end + + it "answers #paid? like #paid_in_full?, matching EventRegistration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.paid?).to be(false) + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 10_000) + + expect(ce_reg.paid?).to be(true) + end end end From 7ca23ac5673f63a54d0023fd20f7f2f1408cf013 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 22:03:17 -0400 Subject: [PATCH 09/13] Document the deliberate zero-cost CE asymmetry The Allocation guard rejects allocating to a zero-cost CE while CE#paid_in_full? treats one as paid. Comment both sides so a future reader doesn't 'fix' one to match the other. Co-Authored-By: Claude Opus 4.8 --- app/models/allocation.rb | 4 ++++ app/models/continuing_education_registration.rb | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/app/models/allocation.rb b/app/models/allocation.rb index f709b58c20..f5872cdeef 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -120,6 +120,10 @@ def validate_ce_registration_cost ce_reg = allocatable return unless ce_reg.is_a?(ContinuingEducationRegistration) + # cost_cents is a non-null, default-0 column, so `<= 0` (not `.blank?`, as in + # the event variant whose cost is nullable) is the right "no cost" test. A + # zero-cost CE accepts no allocations even though CE#paid_in_full? reports it + # as paid — that asymmetry is deliberate, see CE#paid_in_full?. cost_cents = ce_reg.cost_cents.to_i if cost_cents <= 0 errors.add(:base, "Cannot allocate to a CE registration with no cost.") diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index bf97faae92..c9b51dab08 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -37,6 +37,11 @@ def remaining_cost [ cost_cents - allocations_sum, 0 ].max end + # A zero-cost CE registration counts as paid (allocations_sum >= 0). That's + # intentional and only ever reached for already-existing zero-cost records: + # Allocation#validate_ce_registration_cost forbids *allocating* to a zero-cost + # CE in the first place, so the two layers encode "no cost" with opposite + # intent on purpose — nothing to pay here vs. nothing may be paid there. def paid_in_full? allocations_sum >= cost_cents.to_i end From 41d8c270268201518f2d2f950d7c7784448221b3 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 28 Jun 2026 22:19:08 -0400 Subject: [PATCH 10/13] Rename event CE hours field and derive eligibility from it ce_hours_available -> ce_hours_offered reads as the credit the event is worth (present/possible, not past tense). Drop the redundant ce_hours_eligible boolean: Event#ce_eligible? now derives from a positive ce_hours_offered, giving a single source of truth. Co-Authored-By: Claude Opus 4.8 --- app/models/continuing_education_registration.rb | 2 +- app/models/event.rb | 7 +++++++ app/policies/event_policy.rb | 3 +-- app/views/events/_form.html.erb | 4 ++-- ...20260625152706_add_ce_hours_fields_to_events.rb | 8 +++----- .../20260629010238_rename_event_ce_fields.rb | 8 ++++---- db/schema.rb | 3 +-- .../continuing_education_registration_spec.rb | 4 ++-- spec/models/event_spec.rb | 14 ++++++++++++++ 9 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index c9b51dab08..603e5bd236 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -71,7 +71,7 @@ def sync_payment_status! # explicitly. Both are plain stored values — no per-hour rate is multiplied out. def default_from_event event = event_registration&.event - self.hours = event.ce_hours_available if event&.ce_hours_available && (hours.blank? || hours.zero?) + self.hours = event.ce_hours_offered if event&.ce_hours_offered && (hours.blank? || hours.zero?) self.cost_cents = event.ce_hours_cost_cents if event&.ce_hours_cost_cents && (cost_cents.blank? || cost_cents.zero?) end diff --git a/app/models/event.rb b/app/models/event.rb index f2ae513c35..87bfbd9e34 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -252,6 +252,13 @@ def ce_hours_cost=(dollar_amount) end end + # An event grants CE credit when it offers a positive number of hours. Derived + # from ce_hours_offered rather than a separate stored flag, so there's a single + # source of truth. + def ce_eligible? + ce_hours_offered.to_f.positive? + end + def attachable_content_type "application/vnd.active_record.event" end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index ec132c26ab..14c10eb6a0 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -116,8 +116,7 @@ def google_analytics? :event_details_label, :ce_hours_details, :ce_hours_details_label, - :ce_hours_eligible, - :ce_hours_available, + :ce_hours_offered, :ce_hours_cost, :autoshow_cost, :autoshow_date, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 3bcdb7db91..1a7f78b89e 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -473,8 +473,8 @@ content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %>