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..704de50eea 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 @@ -478,6 +479,23 @@ def ce_hours_cost=(dollar_amount) end end + 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..87a6e229fe 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -137,29 +137,61 @@ +
+ <%= 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.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" %> +
+
+ <%= f.label :discount_amount, "Discount amount", class: "block font-medium mb-1" %>
$ - - <%= 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" %> + <%= 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" %>
- - <% if f.object.errors[:cost].any? %> -

Cost <%= f.object.errors[:cost].join(", ") %>

- <% end %>
+
+ + <% 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/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" %> 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 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