Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916
Conversation
| # 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) |
There was a problem hiding this comment.
🤖 From Claude: number.presence collapses ""→nil, so a CE opt-in with no number on file resolves to the person's single placeholder license rather than spawning a new blank row each time. The DB unique index on (person_id, number) enforces the numbered case (MySQL treats NULLs as distinct, so it intentionally does not block multiple placeholders — the model does).
|
|
||
| # Advance requested↔paid to track real payments without clobbering a later | ||
| # admin state (issued/unawarded). Called when allocations change. | ||
| def sync_payment_status! |
There was a problem hiding this comment.
🤖 From Claude: Auto-advances requested↔paid to track real payments, but only from those two states — a later admin issued/unawarded is never clobbered. Driven from Allocation after_create, so reversals (negative allocations) revert paid→requested too.
| self.amount_cents = (hours.to_d * rate_cents).round | ||
| end | ||
|
|
||
| def license_belongs_to_registrant |
There was a problem hiding this comment.
🤖 From Claude: Integrity guard: the license must belong to the registrant, otherwise one person's CE could be billed against another's license.
| end | ||
| end | ||
|
|
||
| def validate_ce_registration_cost |
There was a problem hiding this comment.
🤖 From Claude: Mirrors validate_event_registration_cost, but caps allocations at the CE registration's own amount_cents (= hours × rate). With two licenses billed once, a single Payment allocates across both CE registrations and each is capped independently here.
jmilljr24
left a comment
There was a problem hiding this comment.
Just the cost calculation is the only thing that gives me pause. Thoughts?
| def ce_amount_owed_cents | ||
| return 0 if ce_hours.blank? | ||
|
|
||
| (ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round |
There was a problem hiding this comment.
I don't think this aligns with the stakeholders new requirements for CE payments.
Prior to the last meeting, yes it was a direct correlation of ce_hour * hourly_rate but they currently the want essential a flat rate. If you want CE credits you pay $120 for this event (how they come up with that number is irrelevant). No refunds. If you only complete 6 hours, you only get credit for 6 hours.
I'm thinking a column on event for ce_cost. This avoids have a hard coded constant and no math involved.
There was a problem hiding this comment.
yes, i was thinking same on event ce_hour_cost, but was going to keep our math stuff, just have it all end up with the same math.
There was a problem hiding this comment.
I'm not hard pressed on this, but I guess my concern is that we are adding an additional step and logic that isn't needed. On the flip side if they change their mind in the future this would open up that ability. But then again that's just guessing and they may come up with some other formula for cost.
There was a problem hiding this comment.
yeah totally fair. what i'm hearing is there's a fixed cost for training events. and i've also heard about them doing future kinds of events that aren't training events. so going forward it's a toss-up. but i know in the past they captured partial amounts and payments. since this system needs to accommodate historical data too, i was thinking keep this data structure underneath and have the calculation logic just run in the background to get the current fixed cost handled.
There was a problem hiding this comment.
Good point on the filemaker data. I wasn't thinking of that.
Maybe a typo on "event"? They are dong a fixed cost for "CE Hours".
I took a look at the filemarker data. There are 167 records on CE hours. The are all a simple payment recording on an "CE hours event". 99% are $120 with a note saying 12 hours.
The handful of outliers I see in notes are:
11 hours - 120 paid
10 hours - 100 paid
30 hours - 30 paid
12 hours - waived
fee comped - no mention of hours
120 paid - did not attented
The only other odd two are a combined payment for a training event and CE hours.
With that said, there is no column for cost_per_hour. So I think as far as filemaker data goes it will be easiest to keep it the 1:1 , payments = X and CE_hours = X. It seems roundabout and brittle to take those two datapoints, divide them to get the hour rate, then take the original hour times the calculated rate to get back the payment we started with.
There was a problem hiding this comment.
ok @jmilljr24 cost calculations removed. i extracted the registration interface (payments & certificate) from event_registration into a registerable concern so it can be shared exactly with continuing_education_registration, and added certificate_sent_at for both since it's my understanding they issue those for both (and i wanted to keep them as mirrors of each other)
| expires_on.present? && expires_on.past? | ||
| end | ||
|
|
||
| def label |
There was a problem hiding this comment.
Maybe in a decorator in part 2? I don't recall what pattern we've been using the most for stuff like this.
There was a problem hiding this comment.
We don't really have a pattern. A better name is prob name.
b3b8ea7 to
abaee56
Compare
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Clearer admin-facing wording for the terminal non-issuance state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…elivery EventRegistration and ContinuingEducationRegistration had a near-identical allocatable payment interface; extract it into a Registerable concern (driven by each includer's cost_cents — EventRegistration delegates to event.cost_cents, CE has its own column) so the two can't drift. The concern also carries the shared certificate delivery (certificate_sent_at / certificate_sent? / mark_certificate_sent!), while each model keeps its own certificate_available? eligibility. CE payment is now computed (paid?) like EventRegistration, so the stored payment status is gone: drop the CE status column, sync_payment_status!, and the Allocation after_create sync. Certificates are handled the same way on both — availability computed, delivery recorded via certificate_sent_at (added to event_registrations) — so issued_at (redundant: sending the email is issuing) is dropped. CE spec gains payment-method coverage mirroring EventRegistration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CE tables and fields are all new in this PR, so the incremental rename/add/drop migrations were just churn against schema that never shipped. Fold them into the migrations that introduce each field: events gets ce_hours_offered + ce_hours_cost_cents directly; continuing_education_registrations is created with its final columns (cost_cents + certificate_sent_at, no stored status/rate); event_registrations#certificate_sent_at stands alone (pre-existing table). Drops the add_ce_hour_cost / rename_ce_amount_to_cost_drop_rate / rename_event_ce_fields / unify_certificate_columns migrations. schema.rb is unchanged and the full history replays cleanly from an empty database. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
abaee56 to
a7e7d3f
Compare
| def paid? | ||
| paid_in_full? | ||
| end |
There was a problem hiding this comment.
| def paid? | |
| paid_in_full? | |
| end |
I personally would just keep one consistent method name if it's exactly the same.
There was a problem hiding this comment.
We may have been using this pattern other places, it just caught my eye in this.
|
|
||
| def validate_ce_registration_cost | ||
| ce_reg = allocatable | ||
| return unless ce_reg.is_a?(ContinuingEducationRegistration) |
There was a problem hiding this comment.
| return unless ce_reg.is_a?(ContinuingEducationRegistration) |
nbd but this is redudant to
validate :validate_ce_registration_cost, if: -> { allocatable_type == "ContinuingEducationRegistration" }
| private | ||
|
|
||
| # 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. |
There was a problem hiding this comment.
| # explicitly. Both are plain stored values — no per-hour rate is multiplied out. | |
| # explicitly. |
|
|
||
| # Virtual attribute for the total CE cost in dollars (converts to/from | ||
| # ce_hours_cost_cents), mirroring #cost. | ||
| def ce_hours_cost |
There was a problem hiding this comment.
I don't know the naming convention we've been using but I feel like this might bite us at some point. cost_cents is explicit. Does hours_cost mean dollars or cents. I can't tell unless I go to this method.
Then in registerable.rb we have
def remaining_cost
which returns cents.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🤷♀️
jmilljr24
left a comment
There was a problem hiding this comment.
Looking good! Nice idea on registerable.
|
ooo, great points! will do those before merge. |
- Remove Registerable#paid? — it was an exact alias of paid_in_full?, which is the name used everywhere; switch the two internal callers over. - Drop the is_a? guards in Allocation's cost validators; the validate :if allocatable_type condition already gates them. Behavioral spec replaces the white-box one that called the private method directly. - Trim the default_from_event comment (the per-hour-rate caveat described an abandoned design). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The foundation PR (#1916) squash-merged into main with a later migration (add_certificate_sent_at, 20260629023519) than this branch's drop migration (20260625162312), leaving it out of order. Retimestamp it to run last and bump schema.rb to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Main (#1916) established Event#ce_eligible? (ce_hours_offered > 0) as the single source of truth for whether an event grants CE credit. Drop the branch's own offers_ce? (which checked the registration form for the ce_credit_interest field) and gate every CE surface on ce_eligible? so the two can't drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add CE status column and filter to registrants index Admins need to see and triage registrants' continuing-education credit standing at a glance — who requested CE, who is missing a license number or hours, and who has paid — without opening each registration. Surfaces this as a column and dropdown filter that only appear once an event has CE requests, so non-CE events stay uncluttered, and mirrors the column in the CSV export for parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Gate CE surfaces on the registration form, toggle the index column CE only matters for events whose registration form actually asks the magic CE-interest question, so empty CE columns/filters were noise on every other event. Gate all CE UI — registrants index, onboarding matrix, bulk-reminder filter, and both CSV exports — on Event#offers_ce? (the form carries the ce_credit_interest field) instead of "any registrant happened to have CE data". On the registrants index the CE status column now sits behind its own slide toggle (defaulting off) alongside "User confirmation", generalizing the column-toggle controller to support multiple named groups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Gate CE on Event#ce_eligible? instead of a parallel offers_ce? Main (#1916) established Event#ce_eligible? (ce_hours_offered > 0) as the single source of truth for whether an event grants CE credit. Drop the branch's own offers_ce? (which checked the registration form for the ce_credit_interest field) and gate every CE surface on ce_eligible? so the two can't drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Polish registrants roster: CE column default-on, layout + density - Default the CE status column on (toggle on) when the event is ce_eligible; add per-column toggles (CE, Attendance off-by-default, User confirmation) by generalizing column-toggle to named groups. - Reorder columns: CE before Scholarship; Date registered moved to the far right after Edit; CE filter dropdown before Scholarship; filters wrapped in a card. - Reclaim width: compact registrant profile button (teeny avatar, smaller type), drop leading chip icons, retune column widths/truncation. - CE cell mirrors the scholarship pill and links to the registration edit (CE section); not-requested shows a "Create" chip. - Viewing a submission from the roster now navigates in the same tab and the eyebrow returns to that registrant's row (anchored + highlighted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Tune registrant profile button to a uniform w-40 name-display width Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Widen organization chips (truncate at 52) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Size org chips to fit a full agency name; widen profile column to w-44 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Uniform person column sized to ~30 chars (truncate names at 30) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Color the present registration-form icon blue instead of green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Roster toggles: Organization + Scholarship columns, CE label, icon polish - Add Organization (on) and Scholarship (on when the event charges a fee) column toggles; Scholarship column hidden by default on free events. - Order toggles: Organization, CE, Scholarship, Attendance, User confirmation. - Rename the CE toggle and column header to "CE". - Registration-form icon: blue outline (fa-regular), shown only when a form was submitted; reserve a fixed slot otherwise so comment/warning icons stay aligned. Comments icon switched to outline. - Person column a uniform w-60 (names truncate at 30). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Recolor CE cell states to mirror scholarship Requested → orange; once a license is on file show the CE amount due in orange; paid shows blue Recipient; gray Create when CE wasn't requested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Registrants roster: CE record progression, comment/org filters, eyebrows - CE cell now walks the ContinuingEducationRegistration lifecycle: Requested → No license # → amount due → Recipient (paid); eager-load CE records. - Comment icon: orange-filled when any comment is flagged, otherwise the new `comments` theme lilac; clickable to the registration's comments section. - Add a `comments` DomainTheme color; reuse it on the registration edit comments card. Make the shout-out field a textarea. - New filters: Comments (None/Present/Flagged) and Organization (Pending/Linked), with scopes. Filters restructured into two rows, ordered to match the columns (CE, Scholarship, Payment), wider keyword box, grey Clear button. - Reg-edit: org card narrower (even thirds) so scholarship/CE are wider. - Every "Back to registrants" eyebrow (edit, link-org, public submission, scholarships, allocations, person profile) now anchors + highlights the row. - Index row highlight recolored amber → yellow. - AI files: prefer decorators over helpers for model-specific presentation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Extract shared events/filter_select partial for roster + reminder filters Both the registrants roster and the bulk-reminder recipient filters now render their dropdowns through one events/filter_select partial (each page keeps its own text inputs, dropdown set, and options). DRYs the select markup without changing any param names, options, or filtering behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Account-status filter, shared filter layout, yellow highlight fix - Add a User-account filter (None/Invited/Has access/No access) to the registrants roster via an account_status scope (guards NULL person_ids so NOT IN works); shares the filter_select partial with the reminder page. - Align the reminder filter layout to the roster: inputs + dropdowns are flex rows of w-48 controls that wrap (so all inputs fit a row); grey Clear button. - Move the grey-placeholder behavior into the shared filter_select partial so both pages get it. - Narrow the State dropdown so Clear fits on the row. - Darken the index row highlight ring (yellow-400) so it reads yellow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * CE chip says 'Filed' once a license is on file (amount due in tooltip) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Safelist ring colors so the highlight builds; CE Filed=blue, Recipient=green The row-highlight ring rendered black because ring-<color> utilities were never in the Tailwind @source safelist (only bg/border/text were), so the class fell back to currentColor. Add a ring- safelist line; the highlight now builds as a real (dark yellow) ring. Also recolor the CE chip to mirror the registration flow: Filed → blue, Recipient (paid) → green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Shrink the shout-out textarea to a single row Keep it a textarea (multi-line on demand) but default to one row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Registrants filters: keyword flexes, Payment after Attendance, thinner dropdowns Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Keyword uses min-w-0 so row-1 filters stay one row (wrap only when cramped) The arbitrary min-w-[16rem] never compiled; min-w-0 lets the keyword flex and shrink so the dropdowns share one horizontal row on desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Sync recipients filters with the registrants roster Render the same dropdown filter set (attendance, payment, CE, scholarship, organization, comments, user account, state, county) on the bulk-reminder recipients page, sharing the events/filter_select partial. ReminderRecipientFilter now keeps its free-text inputs in memory but delegates the dropdowns to the same EventRegistration scopes the roster uses, so param names, options, and semantics stay identical (and filters can carry between the two pages). preview_reminder builds @dashboard for the state/county options. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…umns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…umns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…umns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…umns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 PR, suggested 👤 review level: 🔬 Inspect — new data model + migrations (adds two tables, drops two Person columns)
What is the goal of this PR and why is this important?
CE data lives as three flat columns on
EventRegistration, which can't model what AWBW needs: CE hours tracked per professional license, a person holding several licenses, and a CE record with its own payment + certificate lifecycle.This is PR 1 of 2 — the foundation only (additive, no behavior change). PR 2 reroutes intake, the callout, and read sites, then drops the old
ce_*columns.How did you approach the change?
ProfessionalLicense(perPerson) — nullablenumberis a placeholder;find_or_create_forkeeps one license per(person, number)and a single placeholder. Nostatus(derivednumber_known?/expired?).ContinuingEducationRegistration(perEventRegistration, one per license) —allocatableso it reuses the existing Payment/Allocation machinery; stringstatus(requested→paid→issued/unawarded, no enum);hoursdefaults from the event but stays editable, drivingamount_cents.Eventgainsce_hours_eligible(the explicit "CE offered" gate) +ce_hours(fractional hours).Allocationgets a CE over-payment guard and arequested→paidstatus sync.Person#license_number/license_typecolumns (no data) and their form/controller bits.Anything else to add?
The existing
ce_*columns and the CE form are deliberately left untouched here; suite stays green. Billing model: two licenses charge 2× but bill once (one payment allocated across the CE registrations).Interface comparison:
ContinuingEducationRegistrationvsEventRegistration(incl.Allocationcost-validation pair)CE deliberately mirrors only the allocatable/payment seam of
EventRegistrationso both behave identically wherever allocations are summed. Everything else diverges because CE is a lightweight, audited child record of anEventRegistration(scoped to one professional license), not a first-class resource.registrant(Person)belongs_toevent_registration.registrant)eventbelongs_toevent_registration.event)event_registrationbelongs_toprofessional_licensebelongs_tocreated_by/updated_bybelongs_to … optional: truecomments,…_organizations,organizations,notifications,scholarships,checklist_completionshas_manycontinuing_education_registrationshas_many … dependent: :destroyallocations(polymorphic)has_manyhas_many … dependent: :destroyallocatable; CE addsdependent: :destroypaymentsthrough allocationshas_many … through: :allocationsaccepts_nested_attributes_forcomments,notifications,registranthas_paper_trailbefore_create :generate_slugbefore_validation :default_from_event, on: :createhours/cost_centsafter_commit :send_cancellation_emailsAllocationcallback →sync_payment_status!ACTIVE/INACTIVE/ATTENDANCE_STATUSES(attendance)STATUSES = requested/paid/issued/unawarded(fulfillment)CHECKLIST_STEPS,DAY_FIELDS,CE_HOURLY_RATE_DOLLARSregistrant_idunique perevent_id,event_idpresence,sluguniquenessstatusinclusionin: ATTENDANCE_STATUSESin: STATUSESce_hours_requested ≥ 0hours ≥ 0,cost_cents ≥ 0license_belongs_to_registrantsearch_by_params,remote_searchable_byallocations_sumpayments_sumremaining_cost[event.cost_cents - sum, 0].max[cost_cents - sum, 0].maxpaid_in_full?≤ 0 → true), elsesum ≥ event.cost_centssum ≥ cost_cents.to_i(no free short-circuit)partially_paid?sync_payment_status!requested↔paid, leavesissued/unawarded)paid?paid_in_full?)payment_status_label,discounted?,discount_sumintends_to_pay, so its label's middle state is a real partial paymentpayment_access_granted?,intends_to_pay?name,active?,attended?,scholarship?,certificate_available?,joinable?,videoconference_details_visible?,account_status,attendance_status_label,program_statuses, checklist/day helpers,remote_search_label, …default_from_event,license_belongs_to_registrantEventRegistrationsController+ full routesAllocationcost-validation pair —validate_event_registration_costvsvalidate_ce_registration_costare structurally identical (sameamount_was/persisted?self-exclusion, same already-paid + over-allocation math, sameMoneyFormattermessages). Two intended differences:event_reg.event.cost_centsce_reg.cost_cents.to_icost_cents.blank?→ "free event"cost_cents <= 0→ "no cost"null: false, default: 0