-
Notifications
You must be signed in to change notification settings - Fork 24
Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation) #1916
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7697dd3
4db30ed
f762227
20adfcc
6e6aabb
1458a76
652f70e
776f0f2
7ca23ac
41d8c27
fe0f95c
a7e7d3f
2464e4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Integrity guard: the license must belong to the registrant, otherwise one person's CE could be billed against another's license. |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know the naming convention we've been using but I feel like this might bite us at some point. Then in registerable.rb we have which returns cents.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like event "cost" is also passed into the controller as dollars and saved as cents so this consistent with that. Just makes me think it could be easy to mix up.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah we have a little pattern going that cost is dollars and user-facing cost_cents is what is stored. i've tried a couple options at various times/models and this seems best split of the difference π€·ββοΈ |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π€ From Claude: Mirrors
validate_event_registration_cost, but caps allocations at the CE registration's ownamount_cents(= hours Γ rate). With two licenses billed once, a single Payment allocates across both CE registrations and each is capped independently here.