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..ab63e0cd64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ This codebase (Rails 8.1) | `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/concerns/` | Shared model modules | 15 concerns | +| `app/models/concerns/` | Shared model modules | 16 concerns | ### Presentation @@ -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` (`Registerable`) with stored `hours` + `cost_cents` (default from the event). Payment is computed (no stored status); the certificate is delivered via `certificate_sent_at` and gated by its own `certificate_available?` | | `Report` | STI base class for MonthlyReport | | `WorkshopLog` | Standalone model for workshop log submissions (attendance, form fields) | @@ -133,6 +135,7 @@ This codebase (Rails 8.1) | `NameFilterable` | Name-based filtering | | `Publishable` | `published`, `publicly_visible` scopes | | `PunctuationStrippable` | Strips punctuation from strings | +| `Registerable` | Shared payment (`allocations_sum`/`paid?`/`remaining_cost`/…) + certificate (`certificate_sent?`, `mark_certificate_sent!`) interface for `EventRegistration` and `ContinuingEducationRegistration`; includers supply `cost_cents` + their own `certificate_available?` | | `RemoteSearchable` | AJAX remote search by column | | `RichTextSearchable` | Full-text search on ActionText rich_text fields | | `SectorsTaggable` | Enforces a single primary sector for sector-tagged owners | 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..734f31e3d0 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -12,6 +12,7 @@ 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 @@ -88,8 +89,6 @@ def self.search_by_params(params) def validate_event_registration_cost event_reg = allocatable - return unless event_reg.is_a?(EventRegistration) - cost_cents = event_reg.event.cost_cents if cost_cents.blank? errors.add(:base, "Cannot allocate to a free event.") @@ -109,6 +108,31 @@ def validate_event_registration_cost end end + def validate_ce_registration_cost + ce_reg = allocatable + # 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.") + return + end + + other_total = ce_reg.allocations_sum + 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/concerns/registerable.rb b/app/models/concerns/registerable.rb new file mode 100644 index 0000000000..4c2c405f26 --- /dev/null +++ b/app/models/concerns/registerable.rb @@ -0,0 +1,65 @@ +module Registerable + extend ActiveSupport::Concern + + # Shared behaviour for the two registration records that money is allocated to + # and that grant a certificate: EventRegistration and + # ContinuingEducationRegistration. Keeps their payment + certificate interfaces + # identical so they can't drift. + # + # Includers must: + # - have an `allocations` association (as: :allocatable) + # - respond to `cost_cents` (EventRegistration delegates to event.cost_cents; + # ContinuingEducationRegistration has its own column) + # - define their own `certificate_available?` (the eligibility rules differ) + # + # The payment reads use the loaded `allocations` association when present, so + # callers that preload it (rosters, onboarding matrix) pay no per-row queries. + + # Total covered by every allocation — payments, discounts, scholarships. + def allocations_sum + return allocations.to_a.sum(&:amount) if allocations.loaded? + allocations.sum(:amount) + end + + # Cash only (excludes scholarships and discounts). + 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 + + # Comp/discount coverage only (excludes payments and scholarships). + def discount_sum + return allocations.to_a.select { |a| a.source_type == "Discount" }.sum(&:amount) if allocations.loaded? + allocations.where(source_type: "Discount").sum(:amount) + end + + def discounted? + return allocations.to_a.any? { |a| a.source_type == "Discount" } if allocations.loaded? + allocations.where(source_type: "Discount").exists? + end + + def remaining_cost + [ cost_cents.to_i - allocations_sum, 0 ].max + end + + # A free (or zero-cost) registration is paid by definition. + def paid_in_full? + return true if cost_cents.to_i <= 0 + allocations_sum >= cost_cents.to_i + end + + def partially_paid? + !paid_in_full? && payments_sum.to_i.positive? + end + + # Certificate delivery. Sending the certificate email is how it's issued, so + # certificate_sent_at doubles as the "issued" marker. Each includer defines its + # own #certificate_available? eligibility. + def certificate_sent? + certificate_sent_at.present? + end + + def mark_certificate_sent!(at: Time.current) + update!(certificate_sent_at: at) + end +end diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb new file mode 100644 index 0000000000..16d697dee8 --- /dev/null +++ b/app/models/continuing_education_registration.rb @@ -0,0 +1,48 @@ +class ContinuingEducationRegistration < ApplicationRecord + include Registerable + + 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" + + before_validation :default_from_event, on: :create + + validates :hours, numericality: { greater_than_or_equal_to: 0 } + validates :cost_cents, numericality: { greater_than_or_equal_to: 0 } + validate :license_belongs_to_registrant + + # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from + # Registerable, driven by this record's own cost_cents column. + + # CE certificate eligibility — its own rule (not shared): the event grants CE, + # the registrant attended, the training has ended, and the CE balance is paid. + def certificate_available? + event = event_registration&.event + return false unless event&.ce_eligible? + + event.end_date&.past? && event_registration.attended? && paid_in_full? + end + + private + + # Snapshot the hours offered and total cost from the event when they aren't set + # explicitly. + def default_from_event + event = event_registration&.event + 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 + + 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..87bfbd9e34 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -236,6 +236,29 @@ def cost=(dollar_amount) end end + # 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_hours_cost=(dollar_amount) + if dollar_amount.blank? + self.ce_hours_cost_cents = nil + else + dollar_amount = dollar_amount.to_s.gsub(/[^\d.]/, "").to_f + self.ce_hours_cost_cents = (dollar_amount * 100).round + 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/models/event_registration.rb b/app/models/event_registration.rb index f2b74d1c05..b776d45898 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -1,5 +1,6 @@ class EventRegistration < ApplicationRecord include RemoteSearchable + include Registerable belongs_to :registrant, class_name: "Person" belongs_to :event @@ -8,6 +9,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 @@ -198,10 +200,6 @@ def checked_in? # checked_in_at.present? end - def paid? - paid_in_full? - end - # True when the registrant should be granted access to ticket materials # (training links, etc.) even though they haven't paid in full yet. Admins # flip the `intends_to_pay` flag when someone commits to paying after the @@ -210,11 +208,11 @@ def paid? # # This is the single seam for "may this registrant reach paid content?": # any payment-gated resource (the videoconference join link today, recordings - # or downloads in the future) should gate on this, NOT on `paid?`. Reporting - # surfaces (rosters, CSV exports, dashboard metrics) must keep using `paid?` / + # or downloads in the future) should gate on this, NOT on `paid_in_full?`. + # Reporting surfaces (rosters, CSV exports, dashboard metrics) must keep using # `paid_in_full?` so they still reflect the real balance owed. def payment_access_granted? - paid? || intends_to_pay? + paid_in_full? || intends_to_pay? end # Human-readable payment status for rosters and CSV exports. Assumes the event @@ -254,41 +252,9 @@ def certificate_available? event.end_date.present? && event.end_date.past? && attended? && scholarship_tasks_met? end - # These read from the loaded `allocations` association so callers that preload - # it (e.g. the registrants roster and onboarding matrix) pay no per-row queries; - # callers that don't load the association once and reuse it across these methods. - def allocations_sum - return allocations.to_a.sum(&:amount) if allocations.loaded? - allocations.sum(:amount) - end - - def remaining_cost - [ event.cost_cents - allocations_sum, 0 ].max - end - - def paid_in_full? - return true if event.cost_cents.to_i <= 0 - allocations_sum >= event.cost_cents.to_i - end - - 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 partially_paid? - !paid_in_full? && payments_sum.to_i.positive? - end - - def discounted? - return allocations.to_a.any? { |a| a.source_type == "Discount" } if allocations.loaded? - allocations.where(source_type: "Discount").exists? - end - - # Total comp/discount coverage (excludes payments and scholarships). - def discount_sum - return allocations.to_a.select { |a| a.source_type == "Discount" }.sum(&:amount) if allocations.loaded? - allocations.where(source_type: "Discount").sum(:amount) + # Cost source for the Registerable payment interface: the event's price. + def cost_cents + event.cost_cents end # True when the registrant has supplied a CE license number. 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..0f07ff774c --- /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 name + [ 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..14c10eb6a0 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_offered, + :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 ce29656eb2..1a7f78b89e 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -471,6 +471,19 @@ 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

" %> +
+ + +
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, diff --git a/app/views/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..f2e028f1cf --- /dev/null +++ b/db/migrate/20260625152706_add_ce_hours_fields_to_events.rb @@ -0,0 +1,15 @@ +class AddCeHoursFieldsToEvents < ActiveRecord::Migration[8.1] + def up + # ce_hours_offered: how many (fractional) hours of CE credit a registrant can + # earn by attending. Distinct from the display-only ce_hours_details copy. + # Eligibility is derived from this value (> 0), not a separate flag. + # ce_hours_cost_cents: the total price charged for those CE hours. + add_column :events, :ce_hours_offered, :decimal, precision: 5, scale: 2 + add_column :events, :ce_hours_cost_cents, :integer + end + + def down + remove_column :events, :ce_hours_offered, if_exists: true + remove_column :events, :ce_hours_cost_cents, 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..125787240e --- /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 + # Total cost for this registration's CE (defaults from the event). Payment + # state is computed from allocations, so no stored payment status. + t.integer :cost_cents, null: false, default: 0 + # Certificate delivery timestamp (set when the certificate email is sent). + 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/migrate/20260629023519_add_certificate_sent_at_to_event_registrations.rb b/db/migrate/20260629023519_add_certificate_sent_at_to_event_registrations.rb new file mode 100644 index 0000000000..5aba7a5a53 --- /dev/null +++ b/db/migrate/20260629023519_add_certificate_sent_at_to_event_registrations.rb @@ -0,0 +1,11 @@ +class AddCertificateSentAtToEventRegistrations < ActiveRecord::Migration[8.1] + # Certificate delivery is recorded the same way as on CE registrations: a + # certificate_sent_at timestamp, set when the certificate email is sent. + def up + add_column :event_registrations, :certificate_sent_at, :datetime unless column_exists?(:event_registrations, :certificate_sent_at) + end + + def down + remove_column :event_registrations, :certificate_sent_at, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1c7a7658e1..c902433e94 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_29_023519) 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,22 @@ 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.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.bigint "professional_license_id", 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 @@ -465,6 +481,7 @@ t.boolean "ce_credit_requested", default: false, null: false t.integer "ce_hours_requested" t.string "ce_license_number" + t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false t.boolean "completed_day_2", default: false, null: false @@ -518,8 +535,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.integer "ce_hours_cost_cents" t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false + t.decimal "ce_hours_offered", precision: 5, scale: 2 t.integer "cost_cents" t.datetime "created_at", null: false t.integer "created_by_id" @@ -961,8 +980,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 +1021,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 +1686,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 +1727,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..f8bb585f40 --- /dev/null +++ b/spec/factories/continuing_education_registrations.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + 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/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..2fce1d02ee 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, 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 + 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:) } @@ -66,10 +92,10 @@ expect(allocation.errors[:base]).to include(a_string_starting_with("Cannot allocate more than remaining")) end - it "skips validation when allocatable is not an EventRegistration" do - allocation = build(:allocation, source: payment, allocatable: registration, amount: 5_000) - allow(allocation).to receive(:allocatable).and_return(nil) - allocation.send(:validate_event_registration_cost) + it "does not run the event-cost validation for a non-EventRegistration allocatable" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + allocation = build(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + expect(allocation).to be_valid expect(allocation.errors[:base]).to be_empty end end diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb new file mode 100644 index 0000000000..e90ab505d1 --- /dev/null +++ b/spec/models/continuing_education_registration_spec.rb @@ -0,0 +1,190 @@ +require "rails_helper" + +RSpec.describe ContinuingEducationRegistration, type: :model do + describe "validations" do + 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 cost defaults from the event" do + it "defaults hours from the event's offered CE hours on create" do + event = create(:event, ce_hours_offered: 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 "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)) + + expect(ce_reg.cost_cents).to eq(12_000) + end + + 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, cost_cents: 5_000, + professional_license: create(:professional_license, person: registration.registrant)) + + expect(ce_reg.cost_cents).to eq(5_000) + end + end + + describe "certificate" do + def ce_reg_for(event:, status:, cost_cents: 0) + registration = create(:event_registration, event: event, status: status) + create(:continuing_education_registration, + event_registration: registration, cost_cents: cost_cents, + professional_license: create(:professional_license, person: registration.registrant)) + end + + it "is available once a CE-eligible training has ended, the registrant attended, and it's paid" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(true) + end + + it "is unavailable when the event does not grant CE" do + event = create(:event, ce_hours_offered: 0, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(false) + end + + it "is unavailable when the registrant has not attended" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "registered").certificate_available?).to be(false) + end + + it "is unavailable while there is a CE balance due" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "attended", cost_cents: 10_000).certificate_available?).to be(false) + end + + it "records delivery via certificate_sent_at" do + ce_reg = create(:continuing_education_registration) + expect(ce_reg.certificate_sent?).to be(false) + ce_reg.mark_certificate_sent! + expect(ce_reg.certificate_sent?).to be(true) + end + end + + # Payment interface comes from Registerable, driven by the CE record's own + # cost_cents. Mirrors EventRegistration's payment-method coverage. + describe "payment interface" do + let(:ce_reg) { create(:continuing_education_registration, cost_cents: 10_000) } + + def pay(reg, amount) + payment = create(:payment, amount_cents: amount, amount_cents_remaining: amount) + create(:allocation, source: payment, allocatable: reg, amount: amount) + end + + def scholarship_for(reg, amount) + scholarship = create(:scholarship, recipient: reg.event_registration.registrant, amount_cents: amount) + create(:allocation, source: scholarship, allocatable: reg, amount: amount) + end + + describe "#paid_in_full?" do + it "is paid when the registration is zero-cost" do + zero = create(:continuing_education_registration, cost_cents: 0) + expect(zero).to be_paid_in_full + end + + it "is paid when a scholarship covers the cost" do + scholarship_for(ce_reg, 10_000) + expect(ce_reg).to be_paid_in_full + end + + it "is paid when payments cover the cost" do + pay(ce_reg, 10_000) + expect(ce_reg).to be_paid_in_full + end + + it "is not paid when allocations are insufficient" do + pay(ce_reg, 5_000) + expect(ce_reg).not_to be_paid_in_full + end + end + + describe "#partially_paid?" do + it "is false when nothing has been paid" do + expect(ce_reg).not_to be_partially_paid + end + + it "is true when a payment covers some but not all of the cost" do + pay(ce_reg, 5_000) + expect(ce_reg).to be_partially_paid + end + + it "is false when only a scholarship covers part of the cost" do + scholarship_for(ce_reg, 5_000) + expect(ce_reg).not_to be_partially_paid + end + + it "is false when paid in full" do + pay(ce_reg, 10_000) + expect(ce_reg).not_to be_partially_paid + end + end + + describe "#discounted? / #discount_sum" do + it "are set by a discount allocation" do + create(:allocation, source: create(:discount, amount_cents: 4_000), allocatable: ce_reg, amount: 4_000) + expect(ce_reg).to be_discounted + expect(ce_reg.discount_sum).to eq(4_000) + end + + it "ignore a payment-only allocation" do + pay(ce_reg, 4_000) + expect(ce_reg).not_to be_discounted + expect(ce_reg.discount_sum).to eq(0) + end + end + + describe "#payments_sum vs #allocations_sum" do + it "counts cash for payments_sum and every source for allocations_sum" do + pay(ce_reg, 4_000) + scholarship_for(ce_reg, 3_000) + ce_reg.reload + + expect(ce_reg.payments_sum).to eq(4_000) + expect(ce_reg.allocations_sum).to eq(7_000) + expect(ce_reg.remaining_cost).to eq(3_000) + end + end + + it "issues no per-row queries when allocations are preloaded" do + pay(ce_reg, 10_000) + preloaded = ContinuingEducationRegistration.includes(:allocations).find(ce_reg.id) + + queries = [] + subscriber = ->(*, payload) { queries << payload[:sql] unless payload[:name] == "SCHEMA" } + ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do + preloaded.allocations_sum + preloaded.payments_sum + preloaded.discounted? + preloaded.paid_in_full? + preloaded.partially_paid? + end + + expect(queries).to be_empty + expect(preloaded.paid_in_full?).to be(true) + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 5f5b40cfc2..a18611d666 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -315,4 +315,36 @@ expect(results).not_to include(art_event, music_event) end end + + 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 "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_hours_cost: 150).ce_hours_cost_cents).to eq(15_000) + end + + it "clears the cents when assigned blank" do + expect(build(:event, ce_hours_cost: "").ce_hours_cost_cents).to be_nil + end + end + + describe "#ce_eligible?" do + it "is true when the event offers a positive number of CE hours" do + expect(build(:event, ce_hours_offered: 6)).to be_ce_eligible + end + + it "is false when no CE hours are offered" do + expect(build(:event, ce_hours_offered: nil)).not_to be_ce_eligible + end + + it "is false when CE hours are zero" do + expect(build(:event, ce_hours_offered: 0)).not_to be_ce_eligible + 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