From 85122002c11f0348f9d0928f25a50ab2c57bffff Mon Sep 17 00:00:00 2001
From: Justin Miller <16829344+jmilljr24@users.noreply.github.com>
Date: Fri, 4 Sep 2026 11:35:28 -0400
Subject: [PATCH 1/5] add discount code to event
---
db/migrate/20260904000000_add_discount_code_to_events.rb | 6 ++++++
db/schema.rb | 4 +++-
2 files changed, 9 insertions(+), 1 deletion(-)
create mode 100644 db/migrate/20260904000000_add_discount_code_to_events.rb
diff --git a/db/migrate/20260904000000_add_discount_code_to_events.rb b/db/migrate/20260904000000_add_discount_code_to_events.rb
new file mode 100644
index 0000000000..84d40e829f
--- /dev/null
+++ b/db/migrate/20260904000000_add_discount_code_to_events.rb
@@ -0,0 +1,6 @@
+class AddDiscountCodeToEvents < ActiveRecord::Migration[8.1]
+ def change
+ add_column :events, :discount_code, :string
+ add_column :events, :discount_amount_cents, :integer
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d4cdeda908..8bd31f5618 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_09_01_135946) do
+ActiveRecord::Schema[8.1].define(version: 2026_09_04_000000) 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
@@ -627,6 +627,8 @@
t.datetime "created_at", null: false
t.integer "created_by_id"
t.text "description"
+ t.integer "discount_amount_cents"
+ t.string "discount_code"
t.datetime "end_date", precision: nil
t.boolean "facilitator_training", default: false, null: false
t.boolean "featured", default: false, null: false
From 5f42a1cb87e3e2a4f7b74a873cd411d09cbbb069 Mon Sep 17 00:00:00 2001
From: Justin Miller <16829344+jmilljr24@users.noreply.github.com>
Date: Fri, 4 Sep 2026 11:42:29 -0400
Subject: [PATCH 2/5] add discount to event reg flow
---
.../events/public_registrations_controller.rb | 13 ++++++++++-
app/models/event.rb | 19 ++++++++++++++++
app/policies/event_policy.rb | 2 ++
app/views/events/_form.html.erb | 22 +++++++++++++++++++
.../events/public_registrations/new.html.erb | 4 ++++
5 files changed, 59 insertions(+), 1 deletion(-)
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index 4a433d82bb..8773eb7ec4 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -15,6 +15,7 @@ def new
return
end
+ @discount_code = params[:discount_code] if @event.discount_code? && params[:discount_code].present?
@form_fields = visible_form_fields
@scholarship = scholarship_mode?
@scholarship_form = @event.scholarship_form if @scholarship
@@ -76,6 +77,8 @@ def create
# events (flushed after this action) so every record it wrote is traceable.
Current.form_submission_id = result.form_submission&.id
+ apply_discount_code(registration)
+
if !registration.scholarship_requested? && @event.cost_cents.to_i > 0 && credit_card_payment?(registration_params)
checkout_session = create_stripe_checkout_session(registration, result.form_submission)
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
@@ -145,6 +148,14 @@ def show
private
+ def apply_discount_code(registration)
+ code = params[:discount_code].to_s.strip
+ return unless @event.discount_code? && code.casecmp?(@event.discount_code)
+
+ discount = Discount.create!(amount_cents: @event.discount_amount_cents)
+ Allocation.create!(source: discount, allocatable: registration, amount: @event.discount_amount_cents)
+ end
+
# A file input can't be repopulated, so a form re-rendered after a validation
# error carries the already-uploaded blob's signed id in retained_uploads.
# An untouched file input still posts a blank value, so fall back to the
@@ -170,7 +181,7 @@ def credit_card_payment?(form_params)
def create_stripe_checkout_session(registration, submission = nil)
person = registration.registrant
- amount = @event.cost_cents
+ amount = registration.remaining_cost
metadata = { event_registration_id: registration.id, event_id: @event.id }
metadata[:form_submission_id] = submission.id if submission
diff --git a/app/models/event.rb b/app/models/event.rb
index 3635ffdf84..5e6b39cfa4 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -478,6 +478,25 @@ def ce_hours_cost=(dollar_amount)
end
end
+ # Virtual attribute for discount amount in dollars (converts to/from
+ # discount_amount_cents), mirroring #cost.
+ def discount_amount
+ return nil if discount_amount_cents.nil?
+ discount_amount_cents / 100.0
+ end
+
+ def discount_amount=(dollar_amount)
+ if dollar_amount.blank?
+ self.discount_amount_cents = nil
+ else
+ self.discount_amount_cents = (dollar_amount.to_s.gsub(/[^\d.]/, "").to_f * 100).round
+ end
+ end
+
+ def discount_code?
+ discount_code.present? && discount_amount_cents.to_i > 0
+ 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.
diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb
index 4195d502c5..c666bd8610 100644
--- a/app/policies/event_policy.rb
+++ b/app/policies/event_policy.rb
@@ -169,6 +169,8 @@ def google_analytics?
params_filter do |params|
permitted = [ :cost,
+ :discount_code,
+ :discount_amount,
:created_by_id,
:location_id,
:title,
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index edd9b39ba9..050da82e54 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -160,6 +160,28 @@
<% end %>
+
+ <%= f.label :discount_code, "Discount code", class: "block font-medium mb-1" %>
+ <%= f.text_field :discount_code,
+ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ placeholder: "e.g. SAVE50" %>
+
Optional promo code for the registration URL (?discount_code=CODE).
+
+
+
+ <%= f.label :discount_amount, "Discount amount", class: "block font-medium mb-1" %>
+
+ $
+ <%= f.text_field :discount_amount,
+ type: "number",
+ step: "0.01",
+ min: "0",
+ placeholder: "0.00",
+ class: "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
+
+
Fixed dollar amount deducted when the code is used.
+
+
<%= f.label :payment_due_deadline_date, "Payment due by", class: "block font-medium mb-1" %>
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb
index 5b4dfcb602..25c2bb3925 100644
--- a/app/views/events/public_registrations/new.html.erb
+++ b/app/views/events/public_registrations/new.html.erb
@@ -100,6 +100,10 @@
<% end %>
+ <% if @discount_code.present? %>
+
+ <% end %>
+
<%= render "shared/honeypot", scope: "public_registration" %>
From b3d2a4e92172a95ca0aef4b18fe55094809be50e Mon Sep 17 00:00:00 2001
From: Justin Miller <16829344+jmilljr24@users.noreply.github.com>
Date: Fri, 4 Sep 2026 11:46:41 -0400
Subject: [PATCH 3/5] validate discount code
---
app/models/event.rb | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/models/event.rb b/app/models/event.rb
index 5e6b39cfa4..e0765be69d 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -88,6 +88,7 @@ class Event < ApplicationRecord
validates :hint_dates, length: { maximum: 255 }
validates :hint_times, length: { maximum: 255 }
validates :hint_registration_cost, length: { maximum: 255 }
+ validates :discount_code, format: { with: /\A[A-Za-z0-9_-]+\z/, message: "can only contain letters, numbers, hyphens, and underscores" }, allow_blank: true
validate :end_date_not_before_start_date
validate :registration_form_required_when_publicly_registerable, on: :update
validate :staff_members_are_unique, on: :update
From 02dfbb8259f660617cd2e5d54c86bdce3b7a56d9 Mon Sep 17 00:00:00 2001
From: Justin Miller <16829344+jmilljr24@users.noreply.github.com>
Date: Fri, 4 Sep 2026 12:17:57 -0400
Subject: [PATCH 4/5] add specs and adjust form
---
app/views/events/_form.html.erb | 56 ++++++-----
spec/models/event_spec.rb | 75 +++++++++++++++
.../events/public_registrations_spec.rb | 96 +++++++++++++++++++
3 files changed, 204 insertions(+), 23 deletions(-)
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index 050da82e54..87a6e229fe 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -137,35 +137,34 @@
-
-
- <%= f.label :cost, class: "block font-medium mb-1" do %>Event cost
Enter $0 if the event is free<% end %>
-
-
- $
-
- <%= f.text_field :cost,
- type: "number",
- step: "0.01",
- min: "0",
- placeholder: "0.00",
- oninput:
- "if(this.value.includes('.')) { var parts = this.value.split('.'); if(parts[1] && parts[1].length > 2) { this.value = parts[0] + '.' + parts[1].substring(0, 2); } }",
- class:
- "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
-
-
- <% if f.object.errors[:cost].any? %>
-
Cost <%= f.object.errors[:cost].join(", ") %>
- <% end %>
+
+ <%= f.label :cost, class: "block font-medium mb-1" do %>Event cost
Enter $0 if the event is free<% end %>
+
+
+ $
+
+ <%= f.text_field :cost,
+ type: "number",
+ step: "0.01",
+ min: "0",
+ placeholder: "0.00",
+ oninput:
+ "if(this.value.includes('.')) { var parts = this.value.split('.'); if(parts[1] && parts[1].length > 2) { this.value = parts[0] + '.' + parts[1].substring(0, 2); } }",
+ class:
+ "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
+ <% if f.object.errors[:cost].any? %>
+
Cost <%= f.object.errors[:cost].join(", ") %>
+ <% end %>
+
+
+
<%= f.label :discount_code, "Discount code", class: "block font-medium mb-1" %>
<%= f.text_field :discount_code,
class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
placeholder: "e.g. SAVE50" %>
-
Optional promo code for the registration URL (?discount_code=CODE).
@@ -179,9 +178,20 @@
placeholder: "0.00",
class: "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
-
Fixed dollar amount deducted when the code is used.
+
+
+ <% if f.object.discount_code? %>
+
+ Discount link:
+ <%= link_to new_event_public_registration_url(f.object, discount_code: f.object.discount_code),
+ new_event_public_registration_url(f.object, discount_code: f.object.discount_code),
+ class: "text-blue-600 hover:underline", target: "_blank" %>
+
+
(Must click save after changing the code for the link to update)
+ <% end %>
+
<%= f.label :payment_due_deadline_date, "Payment due by", class: "block font-medium mb-1" %>
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 42036a36d6..2714692c16 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -363,6 +363,81 @@
end
end
+ describe "discount code" do
+ describe "validations" do
+ it "allows alphanumeric codes with hyphens and underscores" do
+ event = build(:event, discount_code: "SAVE50-ABC_123")
+ expect(event).to be_valid
+ end
+
+ it "allows blank codes" do
+ event = build(:event, discount_code: "")
+ expect(event).to be_valid
+ end
+
+ it "rejects codes with spaces" do
+ event = build(:event, discount_code: "SAVE 50")
+ expect(event).not_to be_valid
+ expect(event.errors[:discount_code]).to be_present
+ end
+
+ it "rejects codes with URL-unsafe characters" do
+ event = build(:event, discount_code: "SAVE?50&x=y")
+ expect(event).not_to be_valid
+ expect(event.errors[:discount_code]).to be_present
+ end
+ end
+
+ describe "#discount_code?" do
+ it "returns true when code and amount are present" do
+ event = build(:event, discount_code: "SAVE50", discount_amount_cents: 2500)
+ expect(event.discount_code?).to be true
+ end
+
+ it "returns false when code is blank" do
+ event = build(:event, discount_code: nil, discount_amount_cents: 2500)
+ expect(event.discount_code?).to be false
+ end
+
+ it "returns false when amount is zero" do
+ event = build(:event, discount_code: "SAVE50", discount_amount_cents: 0)
+ expect(event.discount_code?).to be false
+ end
+ end
+
+ describe "#discount_amount" do
+ it "converts cents to dollars" do
+ event = build(:event, discount_amount_cents: 2500)
+ expect(event.discount_amount).to eq(25.0)
+ end
+
+ it "returns nil when blank" do
+ event = build(:event, discount_amount_cents: nil)
+ expect(event.discount_amount).to be_nil
+ end
+ end
+
+ describe "#discount_amount=" do
+ it "converts dollar amount to cents" do
+ event = build(:event)
+ event.discount_amount = 25.0
+ expect(event.discount_amount_cents).to eq(2500)
+ end
+
+ it "handles string input" do
+ event = build(:event)
+ event.discount_amount = "10.99"
+ expect(event.discount_amount_cents).to eq(1099)
+ end
+
+ it "sets nil when blank" do
+ event = build(:event)
+ event.discount_amount = ""
+ expect(event.discount_amount_cents).to be_nil
+ end
+ end
+ end
+
describe "#build_public_registration_form" do
let!(:default_form) { create(:form, name: "Short Event Registration") }
let!(:extended_form) { create(:form, name: "Extended Event Registration") }
diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb
index 5f2c90b629..a686f127e8 100644
--- a/spec/requests/events/public_registrations_spec.rb
+++ b/spec/requests/events/public_registrations_spec.rb
@@ -971,4 +971,100 @@ def field(identifier)
expect(unstamped.map(&:first)).to be_empty
end
end
+
+ describe "discount code" do
+ let(:event) { create(:event, cost_cents: 10_000, discount_code: "SAVE50", discount_amount_cents: 2500) }
+ let(:user) { create(:user, :with_person) }
+ let(:form) { create(:form) }
+ let!(:essay_field) do
+ create(:form_field, form: form, answer_type: :free_form_input_paragraph,
+ name: "Tell us why", required: true, min_words: 5)
+ end
+ let!(:first_name_field) do
+ create(:form_field, form: form, field_identifier: "first_name", name: "First name", required: false)
+ end
+ let!(:last_name_field) do
+ create(:form_field, form: form, field_identifier: "last_name", name: "Last name", required: false)
+ end
+ let!(:email_field) do
+ create(:form_field, form: form, field_identifier: "primary_email", name: "Email", required: false)
+ end
+ let!(:payment_method_field) do
+ field = create(:form_field, form: form, field_identifier: "payment_method",
+ name: "Payment method", required: false)
+ FormBuilderService::PAYMENT_METHOD_OPTIONS.each do |option_name|
+ field.form_field_answer_options.create!(answer_option: AnswerOption.find_or_create_by!(name: option_name))
+ end
+ field
+ end
+ let(:fake_session) { double(url: "https://checkout.stripe.com/test", id: "cs_test_123") }
+
+ before do
+ fake_processor = double(checkout: fake_session)
+ allow_any_instance_of(Person).to receive(:set_payment_processor)
+ allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor)
+ end
+
+ def post_with_code(code: nil, payment: nil)
+ fields = {
+ essay_field.id.to_s => "this answer has enough words for validation",
+ first_name_field.id.to_s => "Pat",
+ last_name_field.id.to_s => "Lee",
+ email_field.id.to_s => "pat-#{SecureRandom.hex(4)}@example.com"
+ }
+ fields[payment_method_field.id.to_s] = payment if payment
+
+ params = { public_registration: { Honeypot::FIELD_NAME => "", form_fields: fields } }
+ params[:discount_code] = code if code
+
+ post event_public_registration_path(event), params: params
+ end
+
+ it "creates a discount allocation when code matches" do
+ expect {
+ post_with_code(code: "SAVE50", payment: "Credit card (now)")
+ }.to change(Discount, :count).by(1)
+ .and change(Allocation, :count).by(1)
+
+ registration = EventRegistration.last
+ expect(registration.discount_sum).to eq(2500)
+ expect(registration.remaining_cost).to eq(7500)
+ end
+
+ it "charges the discounted amount to Stripe" do
+ captured = nil
+ fake_processor = double
+ allow(fake_processor).to receive(:checkout) { |args| captured = args; fake_session }
+ allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor)
+
+ post_with_code(code: "SAVE50", payment: "Credit card (now)")
+
+ expect(captured[:line_items].first[:price_data][:unit_amount]).to eq(7500)
+ end
+
+ it "ignores an invalid code" do
+ expect {
+ post_with_code(code: "WRONG", payment: "Credit card (now)")
+ }.not_to change(Discount, :count)
+
+ registration = EventRegistration.last
+ expect(registration.remaining_cost).to eq(10_000)
+ end
+
+ it "matches case-insensitively" do
+ expect {
+ post_with_code(code: "save50", payment: "Credit card (now)")
+ }.to change(Discount, :count).by(1)
+
+ expect(EventRegistration.last.discount_sum).to eq(2500)
+ end
+
+ it "does not create a discount when event has no code" do
+ event.update!(discount_code: nil, discount_amount_cents: nil)
+
+ expect {
+ post_with_code(code: "SAVE50")
+ }.not_to change(Discount, :count)
+ end
+ end
end
From c036735427898cf1ea2e264fb31c370fd968b5c1 Mon Sep 17 00:00:00 2001
From: Justin Miller <16829344+jmilljr24@users.noreply.github.com>
Date: Fri, 4 Sep 2026 12:26:04 -0400
Subject: [PATCH 5/5] clean up
---
app/models/event.rb | 2 --
1 file changed, 2 deletions(-)
diff --git a/app/models/event.rb b/app/models/event.rb
index e0765be69d..704de50eea 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -479,8 +479,6 @@ def ce_hours_cost=(dollar_amount)
end
end
- # Virtual attribute for discount amount in dollars (converts to/from
- # discount_amount_cents), mirroring #cost.
def discount_amount
return nil if discount_amount_cents.nil?
discount_amount_cents / 100.0