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