diff --git a/app/decorators/registration_ticket_callout_decorator.rb b/app/decorators/registration_ticket_callout_decorator.rb
index 026dd6ea9..9c751cf02 100644
--- a/app/decorators/registration_ticket_callout_decorator.rb
+++ b/app/decorators/registration_ticket_callout_decorator.rb
@@ -1,4 +1,16 @@
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, 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)
+ "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..284ba9f32 100644
--- a/app/services/builtin_callout_cards.rb
+++ b/app/services/builtin_callout_cards.rb
@@ -74,6 +74,26 @@ 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?
+ when "videoconference"
+ "this event has no videoconference link" if event.videoconference_url.blank?
+ end
+ end
+
def initialize(event_registration)
@registration = event_registration
@event = event_registration.event
@@ -131,7 +151,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 +178,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 +215,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
@@ -264,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/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..daea66853 100644
--- a/spec/decorators/registration_ticket_callout_decorator_spec.rb
+++ b/spec/decorators/registration_ticket_callout_decorator_spec.rb
@@ -25,4 +25,46 @@
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 "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
+ 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")