From 0a89df3c386150f8ef0664eec74f1642de53ee69 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 00:54:04 -0400 Subject: [PATCH 1/2] Guard payment/scholarship/CE callouts against missing event config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A published built-in callout still can't reach the ticket when the event lacks what the card depends on — a free event has no payment card, an event with no scholarship form has no scholarship card, and an event that offers no CE hours has no CE card. Enforce those gaps in the card builders (one shared BuiltinCalloutCards.config_gap so the ticket guard and editor can't drift) and surface the reason as an amber "won't show" badge next to the callout title in the editor, so admins aren't misled by a published toggle that has no effect. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../registration_ticket_callout_decorator.rb | 11 ++++++ app/services/builtin_callout_cards.rb | 22 ++++++++++- ...egistration_ticket_callout_fields.html.erb | 10 +++++ ...istration_ticket_callout_decorator_spec.rb | 37 +++++++++++++++++++ spec/services/builtin_callout_cards_spec.rb | 28 ++++++++++++-- 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/app/decorators/registration_ticket_callout_decorator.rb b/app/decorators/registration_ticket_callout_decorator.rb index 026dd6ea9..a37911735 100644 --- a/app/decorators/registration_ticket_callout_decorator.rb +++ b/app/decorators/registration_ticket_callout_decorator.rb @@ -1,4 +1,15 @@ class RegistrationTicketCalloutDecorator < ApplicationDecorator + # For the editor's amber warning badge: when this published built-in callout can + # never reach the ticket because the event isn't configured for it (free event → + # no payment card, no scholarship form → no scholarship card, no CE hours → no CE + # card), the reason to show; nil when it will show (or it's hidden/custom). Shares + # BuiltinCalloutCards.config_gap so the badge and the ticket guard can't drift. + def ticket_suppression_reason + return unless published? && builtin? + gap = BuiltinCalloutCards.config_gap(event, builtin_key) + "Won't show on the ticket — #{gap}" if gap + end + # This callout's linked resources as cards, in display order, each reading its # subtitle from the materialized join row and linking to the resource's own # page. Shared by every surface that lists a callout's resources (the handouts diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 88346c126..b846ce682 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -74,6 +74,24 @@ def self.editor_cards(event) "videoconference" => :videoconference_card }.freeze + # Why a built-in card with this builtin_key can never appear on the given event's + # ticket because the event lacks the config the card depends on (a free event + # has no payment card, an event with no scholarship form has no scholarship + # card, an event that offers no CE hours has no CE card) — or nil when the event + # is configured for it. The card builders below enforce these same gaps at + # render time; the editor surfaces the phrase as an amber "won't show" badge so + # admins know a published callout still won't reach the ticket. + def self.config_gap(event, builtin_key) + case builtin_key + when "payment" + "this event is free" if event.cost_cents.to_i <= 0 + when "scholarship" + "this event has no scholarship form" if event.scholarship_form.blank? + when "ce_hours" + "this event offers no CE hours" unless event.ce_eligible? + end + end + def initialize(event_registration) @registration = event_registration @event = event_registration.event @@ -131,7 +149,7 @@ def skip_in_fallback?(builtin_key) # in full. Its page lists every allocation with the running balance, plus the # linked documents (the W-9, and the invoice/receipt) for paid events. def payment_card - return if event.cost_cents.to_i <= 0 + return if self.class.config_gap(event, "payment") due = registration.remaining_cost.to_i.positive? Card.new(icon_class: "fa-solid fa-credit-card", color: due ? "orange" : "blue", title: due ? "Make your payment" : "Payment", @@ -158,6 +176,7 @@ def certificate_card # pending shows an amber "$X · Tasks outstanding" badge (action needed); fully # met shows a fuchsia amount badge. def scholarship_status_card + return if self.class.config_gap(event, "scholarship") return unless registration.scholarship_requested? # Awarded is display-only: the scholarship record exists earlier, but the award # is only shown as awarded once the recipient signs the agreement. Until then @@ -194,6 +213,7 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card + return if self.class.config_gap(event, "ce_hours") return unless registration.ce_registered? complete = registration.ce_license_provided? # An outstanding CE balance turns the card orange (an action card), matching diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 7da76d907..9733f0a88 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -51,6 +51,16 @@ <% end %> <%= f.object.title %> + + <%# Warns when this published built-in is set up but the event isn't + configured for it, so it still won't reach the ticket (free event → no + payment card, no scholarship form / CE hours → no scholarship / CE card). %> + <% if (suppression_reason = f.object.decorate.ticket_suppression_reason) %> + + + <%= suppression_reason %> + + <% end %> <%# Editable content — hidden when the Published checkbox is unchecked. diff --git a/spec/decorators/registration_ticket_callout_decorator_spec.rb b/spec/decorators/registration_ticket_callout_decorator_spec.rb index c795b2f32..aa795b175 100644 --- a/spec/decorators/registration_ticket_callout_decorator_spec.rb +++ b/spec/decorators/registration_ticket_callout_decorator_spec.rb @@ -25,4 +25,41 @@ expect(handouts.href).not_to include("callout_id") end end + + describe "#ticket_suppression_reason" do + def builtin_callout(builtin_key, event:, hidden: false) + create(:registration_ticket_callout, event:, builtin_key:, hidden:, + title: builtin_key.humanize) + end + + it "warns when a published payment callout is on a free event" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 0)) + expect(callout.decorate.ticket_suppression_reason).to eq("Won't show on the ticket — this event is free") + end + + it "warns when a published scholarship callout's event has no scholarship form" do + callout = builtin_callout("scholarship", event: create(:event)) + expect(callout.decorate.ticket_suppression_reason).to include("no scholarship form") + end + + it "warns when a published CE callout's event offers no CE hours" do + callout = builtin_callout("ce_hours", event: create(:event, ce_hours_offered: nil)) + expect(callout.decorate.ticket_suppression_reason).to include("offers no CE hours") + end + + it "is nil when the event is configured for the callout" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 5_000)) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + + it "is nil when the callout is unpublished" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 0), hidden: true) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + + it "is nil for a custom (non-built-in) callout" do + callout = create(:registration_ticket_callout, builtin_key: nil, hidden: false) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + end end diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index 8a7ff97ae..0dbb5c9b8 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -12,6 +12,10 @@ def card(reg, title) described_class.new(reg).cards.find { |c| c.title == title } end + def add_scholarship_form(event) + create(:event_form, :scholarship, event:) + end + describe "#cards" do it "shows the payment card for a bare paid, non-training registration" do expect(card_titles(registration)).to eq([ "Make your payment" ]) @@ -73,12 +77,24 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do + event.update!(ce_hours_offered: 6) expect(card_titles(registration)).not_to include(event.ce_hours_details_label) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) end + it "omits the CE card when the event offers no CE hours, even with a CE registration" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card_titles(registration.reload)).not_to include(event.ce_hours_details_label) + end + + it "omits the scholarship card when the event has no scholarship form, even when requested" do + registration.update!(scholarship_requested: true) + expect(card_titles(registration)).not_to include("Scholarship") + end + it "turns the CE card orange while a balance is due, teal once paid" do event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) license = create(:professional_license, :placeholder, person: registration.registrant) @@ -106,14 +122,14 @@ def card(reg, title) end it "names the CE request deadline on the license-needed badge" do - event.update!(ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 · License number needed by Jul 1") end it "appends the CE payment deadline to the amount-due badge, dropping it once paid" do - event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") ce_reg = create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15") @@ -123,6 +139,7 @@ def card(reg, title) end it "shows the scholarship card only when requested, without an amount chip until awarded" do + add_scholarship_form(event) expect(card_titles(registration)).not_to include("Scholarship") registration.update!(scholarship_requested: true) scholarship_card = card(registration, "Scholarship") @@ -132,6 +149,7 @@ def card(reg, title) end it "prompts to accept the agreement, without an amount chip, until it is signed" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: true, agreement_signed: false) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -142,6 +160,7 @@ def card(reg, title) end it "turns the scholarship card amber while award tasks are outstanding" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, recipient: registration.registrant, tasks_completed: false, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -150,6 +169,7 @@ def card(reg, title) end it "flags an awarded scholarship with outstanding tasks in an amber chip" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: false, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -159,6 +179,7 @@ def card(reg, title) end it "shows a fuchsia amount chip once the agreement is signed and tasks are complete" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: true, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -173,6 +194,7 @@ def card(reg, title) ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) + add_scholarship_form(event) registration.update!(status: "attended", scholarship_requested: true) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) @@ -220,7 +242,7 @@ def card(reg, title) end it "keeps the live CE deadline badge on a materialized CE row" do - event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) callout = create(:registration_ticket_callout, event:, builtin_key: "ce_hours", title: "CE credit") From 83081b6844b67a3c56af05bda4c0e335d039a29c Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:00:18 -0400 Subject: [PATCH 2/2] Also guard the videoconference callout on events with no join link The videoconference card already returns nil when the event has no videoconference URL; route that through config_gap too so a published videoconference callout on an event without a link gets the same amber "won't show" badge in the editor as the payment/scholarship/CE built-ins. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/registration_ticket_callout_decorator.rb | 5 +++-- app/services/builtin_callout_cards.rb | 4 +++- .../decorators/registration_ticket_callout_decorator_spec.rb | 5 +++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/decorators/registration_ticket_callout_decorator.rb b/app/decorators/registration_ticket_callout_decorator.rb index a37911735..9c751cf02 100644 --- a/app/decorators/registration_ticket_callout_decorator.rb +++ b/app/decorators/registration_ticket_callout_decorator.rb @@ -2,8 +2,9 @@ class RegistrationTicketCalloutDecorator < ApplicationDecorator # For the editor's amber warning badge: when this published built-in callout can # never reach the ticket because the event isn't configured for it (free event → # no payment card, no scholarship form → no scholarship card, no CE hours → no CE - # card), the reason to show; nil when it will show (or it's hidden/custom). Shares - # BuiltinCalloutCards.config_gap so the badge and the ticket guard can't drift. + # card, no videoconference link → no videoconference card), the reason to show; + # nil when it will show (or it's hidden/custom). Shares BuiltinCalloutCards.config_gap + # so the badge and the ticket guard can't drift. def ticket_suppression_reason return unless published? && builtin? gap = BuiltinCalloutCards.config_gap(event, builtin_key) diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index b846ce682..284ba9f32 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -89,6 +89,8 @@ def self.config_gap(event, builtin_key) "this event has no scholarship form" if event.scholarship_form.blank? when "ce_hours" "this event offers no CE hours" unless event.ce_eligible? + when "videoconference" + "this event has no videoconference link" if event.videoconference_url.blank? end end @@ -284,7 +286,7 @@ def event_details_card # Shown only when the event has a videoconference URL set. def videoconference_card - return if event.videoconference_url.blank? + return if self.class.config_gap(event, "videoconference") Card.new(icon_class: "fa-solid fa-video", color: "blue", title: "Videoconference", subtitle: "Join link and how to add it to your calendar", diff --git a/spec/decorators/registration_ticket_callout_decorator_spec.rb b/spec/decorators/registration_ticket_callout_decorator_spec.rb index aa795b175..daea66853 100644 --- a/spec/decorators/registration_ticket_callout_decorator_spec.rb +++ b/spec/decorators/registration_ticket_callout_decorator_spec.rb @@ -47,6 +47,11 @@ def builtin_callout(builtin_key, event:, hidden: false) expect(callout.decorate.ticket_suppression_reason).to include("offers no CE hours") end + it "warns when a published videoconference callout's event has no join link" do + callout = builtin_callout("videoconference", event: create(:event, videoconference_url: nil)) + expect(callout.decorate.ticket_suppression_reason).to include("no videoconference link") + end + it "is nil when the event is configured for the callout" do callout = builtin_callout("payment", event: create(:event, cost_cents: 5_000)) expect(callout.decorate.ticket_suppression_reason).to be_nil