diff --git a/AGENTS.md b/AGENTS.md index 79461d4afc..8d068eb229 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ This codebase (Rails 8.1) |---|---|---| | `app/models/` | ActiveRecord models | ~80 files | | `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files | -| `app/jobs/` | SolidQueue background jobs | 5 files | +| `app/jobs/` | SolidQueue background jobs | 6 files | | `app/models/concerns/` | Shared model modules | 16 concerns | ### Presentation diff --git a/app/jobs/event_payment_reminders_job.rb b/app/jobs/event_payment_reminders_job.rb new file mode 100644 index 0000000000..53639ab2e0 --- /dev/null +++ b/app/jobs/event_payment_reminders_job.rb @@ -0,0 +1,68 @@ +# Daily scan (see config/recurring.yml) that emails registrants with an +# outstanding ticket balance as their event's payment deadline approaches and +# once after it passes: a week before, the day before, and one overdue notice. +# Each send flows through NotificationServices::CreateNotification, so it is +# logged in the person's communication history and deduped by notification kind +# (a registration is only ever sent one reminder per phase). +class EventPaymentRemindersJob < ApplicationJob + queue_as :default + + # How far back past a deadline the one-time overdue reminder still fires, so a + # first deploy (or a stalled cron) never blasts long-past deadlines. + OVERDUE_LOOKBACK = 30.days + + # phase => notification kind. Order is only cosmetic; each phase dedupes on its + # own kind, so a registration can receive all three over time. + PHASE_KINDS = { + week: "event_payment_reminder_week", + day: "event_payment_reminder_day", + overdue: "event_payment_reminder_overdue" + }.freeze + + def perform + PHASE_KINDS.each do |phase, kind| + registrations_for(phase).each do |registration| + next if reminder_sent?(registration, kind) + send_reminder(registration, kind) + end + end + end + + private + + # Active, not-paid-in-full registrations on the paid events whose deadline puts + # them in this phase's window. Buddy-payment registrants (someone_else_will_pay) + # are included for now — until we can confirm their payer's intention, an unpaid + # balance still warrants a reminder. + def registrations_for(phase) + EventRegistration + .active + .not_paid_in_full + .where(event_id: events_for(phase)) + .includes(:registrant, :event) + end + + def events_for(phase) + today = Time.zone.today + case phase + when :week then Event.payment_due_on(today + 7.days) + when :day then Event.payment_due_on(today + 1.day) + when :overdue + Event.payment_due_between((today - OVERDUE_LOOKBACK).beginning_of_day, today.beginning_of_day) + end + end + + def reminder_sent?(registration, kind) + Notification.exists?(noticeable: registration, kind: kind) + end + + def send_reminder(registration, kind) + NotificationServices::CreateNotification.call( + noticeable: registration, + kind: kind, + recipient_role: :person, + recipient_email: registration.registrant.preferred_email, + notification_type: 0 + ) + end +end diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index 298afb5c2d..1e180ff8d4 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -16,6 +16,9 @@ def perform(notification_id, persist_delivered_email: true) "event_registration_cancelled" => ->(n) { EventMailer.event_registration_cancelled(n.noticeable) }, "event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) }, "event_registration_reminder" => ->(n) { EventMailer.event_registration_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject) }, + "event_payment_reminder_week" => ->(n) { EventMailer.event_payment_reminder(n.noticeable, phase: :week) }, + "event_payment_reminder_day" => ->(n) { EventMailer.event_payment_reminder(n.noticeable, phase: :day) }, + "event_payment_reminder_overdue" => ->(n) { EventMailer.event_payment_reminder(n.noticeable, phase: :overdue) }, "bulk_payment_confirmation" => ->(n) { EventMailer.bulk_payment_confirmation(n.noticeable) }, "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) } } diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index a846ae0a6d..6bc34f2bf6 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -95,6 +95,36 @@ def event_registration_reminder_fyi(event, recipient_labels, custom_message: nil ) end + # Automated ticket-payment reminder sent by EventPaymentRemindersJob. `phase` is + # one of :week (a week before the deadline), :day (the day before), or :overdue + # (once after the deadline has passed and a balance is still owed) and drives the + # subject line and the body's framing. + def event_payment_reminder(event_registration, phase:) + @event_registration = event_registration + @event = event_registration.event.decorate + @person = event_registration.registrant + @phase = phase.to_sym + @amount_due = event_registration.remaining_cost + + @notification_type = "Event payment reminder" + + @time_zone = @person.user&.time_zone || Time.zone.name + @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") + @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) + + subject = case @phase + when :overdue then "AWBW Portal: Payment past due for #{@event.title}" + else "AWBW Portal: Payment reminder for #{@event.title}" + end + + mail( + to: @person.preferred_email, + from: ENV.fetch("REPLY_TO_EMAIL", "no-reply@awbw.org"), + reply_to: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), + subject: subject + ) + end + def event_registration_cancelled(event_registration) @event_registration = event_registration @event = event_registration.event.decorate diff --git a/app/models/event.rb b/app/models/event.rb index 68aa27ed4e..03d8c2ee01 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -102,6 +102,14 @@ def remote_search_label scope :upcoming, -> { where("start_date >= ?", Date.current) } # Events that charge a registration fee (cost_cents may be nil for free ones). scope :paid, -> { where("cost_cents > 0") } + # Paid events whose ticket payment deadline lands on the given date (in the app + # time zone). Drives the payment reminders' "one week before" / "one day before" + # windows. payment_due_deadline is a datetime, so match against the whole day. + scope :payment_due_on, ->(date) { paid.where(payment_due_deadline: date.all_day) } + # Paid events whose ticket payment deadline has already passed, within the given + # half-open time window (`from...to`). Drives the one-time overdue reminder, + # bounded so a fresh deploy or a stalled cron never blasts long-past deadlines. + scope :payment_due_between, ->(from, to) { paid.where(payment_due_deadline: from...to) } # Events whose start date falls in the given calendar year. Keyed off the year # of start_date directly — a date range would miss same-day times on Dec 31, # since start_date is a datetime and the range's upper bound is midnight. diff --git a/app/models/notification.rb b/app/models/notification.rb index 46cc174ed9..c0e5337772 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -27,6 +27,9 @@ class Notification < ApplicationRecord event_registration_cancelled event_registration_cancelled_fyi event_registration_reminder + event_payment_reminder_week + event_payment_reminder_day + event_payment_reminder_overdue bulk_payment_confirmation bulk_payment_confirmation_fyi idea_submitted diff --git a/app/views/event_mailer/event_payment_reminder.html.erb b/app/views/event_mailer/event_payment_reminder.html.erb new file mode 100644 index 0000000000..d48d2d66e3 --- /dev/null +++ b/app/views/event_mailer/event_payment_reminder.html.erb @@ -0,0 +1,31 @@ +

<%= @phase == :overdue ? "Payment past due" : "Payment reminder" %>

+ +
+

Hello <%= @person.full_name %>,

+ + <% case @phase + when :week %> +

This is a friendly reminder that your payment for the event below is due in about a week.

+ <% when :day %> +

This is a reminder that your payment for the event below is due tomorrow.

+ <% when :overdue %> +

Our records show your payment for the event below is now past due and hasn't been received yet.

+ <% end %> + + <%= render "event_details_card", event: @event, time_zone: @time_zone %> + +
+

Amount due

+

<%= dollars_from_cents(@amount_due) %>

+ <% if (due_display = @event.payment_due_deadline_display) %> +

Due by <%= due_display %>

+ <% end %> +
+
+ +<% if @event_registration.persisted? && @event_registration.slug.present? %> +

You can pay online or view your payment options here:

+

Make your payment

+<% end %> + +

This is an automated reminder from <%= @organization_name %>. If you've already paid, please disregard this message.

diff --git a/app/views/event_mailer/event_payment_reminder.text.erb b/app/views/event_mailer/event_payment_reminder.text.erb new file mode 100644 index 0000000000..d901120298 --- /dev/null +++ b/app/views/event_mailer/event_payment_reminder.text.erb @@ -0,0 +1,25 @@ +<%= @phase == :overdue ? "Payment past due" : "Payment reminder" %> + +Hello <%= @person.full_name %>, +<% case @phase + when :week %> +This is a friendly reminder that your payment for the event below is due in about a week. +<% when :day %> +This is a reminder that your payment for the event below is due tomorrow. +<% when :overdue %> +Our records show your payment for the event below is now past due and hasn't been received yet. +<% end %> +<%= @event.title %> +<% Time.use_zone(@time_zone) do %> + <% if event_dates_detail_label(@event.object).present? %> + <%= event_dates_detail_label(@event.object) %> + <% end %> +<% end %> +Amount due: <%= dollars_from_cents(@amount_due) %> +<% if (due_display = @event.payment_due_deadline_display) %>Due by <%= due_display %> +<% end %> +<% if @event_registration.slug.present? %> +Pay online or view your payment options: +<%= registration_payment_url(@event_registration.slug) %> +<% end %> +-- This is an automated reminder from <%= @organization_name %>. If you've already paid, please disregard this message. diff --git a/config/recurring.yml b/config/recurring.yml index 0dac9be860..21f9790cb7 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -18,6 +18,10 @@ production: class: IssueMembershipInvoicesJob schedule: every day at 3:30am + event_payment_reminders: + class: EventPaymentRemindersJob + schedule: every day at 8am + clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 diff --git a/spec/jobs/event_payment_reminders_job_spec.rb b/spec/jobs/event_payment_reminders_job_spec.rb new file mode 100644 index 0000000000..9fb277b022 --- /dev/null +++ b/spec/jobs/event_payment_reminders_job_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +RSpec.describe EventPaymentRemindersJob, type: :job do + # A paid event whose ticket payment deadline lands a given number of days from + # today (negative = in the past), at noon so it sits inside that whole day. + def event_due_in(days) + create(:event, cost_cents: 135_000, payment_due_deadline: (Time.zone.today + days).to_time.change(hour: 12)) + end + + # Only the payment-reminder kinds this job sends — other notifications (e.g. a + # cancellation email) may exist on the registration independently. + def kinds_for(registration) + Notification.where(noticeable: registration, kind: EventPaymentRemindersJob::PHASE_KINDS.values).pluck(:kind) + end + + around { |example| travel_to(Time.zone.local(2026, 4, 1, 9, 0)) { example.run } } + + describe "#perform" do + it "sends the week reminder for a balance due a week before the deadline" do + registration = create(:event_registration, event: event_due_in(7)) + + expect { described_class.new.perform }.to change { kinds_for(registration) } + .to([ "event_payment_reminder_week" ]) + end + + it "sends the day reminder for a balance due the day before the deadline" do + registration = create(:event_registration, event: event_due_in(1)) + + described_class.new.perform + + expect(kinds_for(registration)).to eq([ "event_payment_reminder_day" ]) + end + + it "sends the overdue reminder once the deadline has passed" do + registration = create(:event_registration, event: event_due_in(-1)) + + described_class.new.perform + + expect(kinds_for(registration)).to eq([ "event_payment_reminder_overdue" ]) + end + + it "does not send outside any reminder window" do + registration = create(:event_registration, event: event_due_in(3)) + + described_class.new.perform + + expect(kinds_for(registration)).to be_empty + end + + it "does not resend the same phase on a second run" do + registration = create(:event_registration, event: event_due_in(7)) + described_class.new.perform + + expect { described_class.new.perform }.not_to change(Notification, :count) + expect(kinds_for(registration)).to eq([ "event_payment_reminder_week" ]) + end + + it "skips a registration paid in full" do + event = event_due_in(7) + registration = create(:event_registration, event:) + create(:allocation, source: create(:payment), allocatable: registration, amount: event.cost_cents) + + described_class.new.perform + + expect(kinds_for(registration)).to be_empty + end + + it "skips a cancelled registration" do + registration = create(:event_registration, event: event_due_in(7), status: "cancelled") + + described_class.new.perform + + expect(kinds_for(registration)).to be_empty + end + + it "still reminds a registration someone else is paying for (intention unknown)" do + registration = create(:event_registration, event: event_due_in(7), someone_else_will_pay: true) + + described_class.new.perform + + expect(kinds_for(registration)).to eq([ "event_payment_reminder_week" ]) + end + + it "skips a free event" do + event = create(:event, cost_cents: 0, payment_due_deadline: (Time.zone.today + 7).to_time.change(hour: 12)) + registration = create(:event_registration, event:) + + described_class.new.perform + + expect(kinds_for(registration)).to be_empty + end + + it "ignores an overdue deadline beyond the lookback window" do + registration = create(:event_registration, event: event_due_in(-(EventPaymentRemindersJob::OVERDUE_LOOKBACK.in_days.to_i + 5))) + + described_class.new.perform + + expect(kinds_for(registration)).to be_empty + end + + it "sends each registration on the same event its own reminder" do + event = event_due_in(1) + 3.times { create(:event_registration, event:) } + + expect { described_class.new.perform } + .to change { Notification.where(kind: "event_payment_reminder_day").count }.by(3) + end + end +end diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index a4291b031e..6c52601059 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -303,4 +303,37 @@ expect(mail.subject).to include("1 registrant ") end end + + describe "#event_payment_reminder" do + let(:event) { create(:event, cost_cents: 135_000, payment_due_deadline: Time.zone.local(2026, 4, 9, 17, 0)) } + let(:event_registration) { create(:event_registration, event:) } + + it "renders each phase without raising" do + %i[ week day overdue ].each do |phase| + expect { described_class.event_payment_reminder(event_registration, phase:).deliver_now }.not_to raise_error + end + end + + it "sends to the registrant" do + mail = described_class.event_payment_reminder(event_registration, phase: :week) + expect(mail.to).to eq([ event_registration.registrant.preferred_email ]) + end + + it "frames the subject as a reminder before the deadline and past due after" do + expect(described_class.event_payment_reminder(event_registration, phase: :week).subject).to include("Payment reminder") + expect(described_class.event_payment_reminder(event_registration, phase: :day).subject).to include("Payment reminder") + expect(described_class.event_payment_reminder(event_registration, phase: :overdue).subject).to include("Payment past due") + end + + it "shows the amount due and the deadline in the body" do + mail = described_class.event_payment_reminder(event_registration, phase: :week) + expect(mail.body.encoded).to include("$1,350") + expect(mail.body.encoded).to include("April 9, 2026") + end + + it "links to the registrant's payment page" do + mail = described_class.event_payment_reminder(event_registration, phase: :day) + expect(mail.body.encoded).to include("/registration/#{event_registration.slug}/payment") + end + end end diff --git a/test/mailers/previews/event_mailer_preview.rb b/test/mailers/previews/event_mailer_preview.rb index 065b3f0725..22d7a3d813 100644 --- a/test/mailers/previews/event_mailer_preview.rb +++ b/test/mailers/previews/event_mailer_preview.rb @@ -22,6 +22,18 @@ def event_registration_reminder_fyi ) end + def event_payment_reminder_week + EventMailer.event_payment_reminder(sample_unpaid_registration, phase: :week) + end + + def event_payment_reminder_day + EventMailer.event_payment_reminder(sample_unpaid_registration, phase: :day) + end + + def event_payment_reminder_overdue + EventMailer.event_payment_reminder(sample_unpaid_registration, phase: :overdue) + end + def event_registration_cancelled event_registration = sample_event_registration event_registration.status = "cancelled" @@ -57,6 +69,16 @@ def sample_event_registration registration end + # A registration with a balance due and a ticket payment deadline, for the + # payment-reminder previews. Cost + deadline set in memory only (not persisted), + # like sample_event_registration's CE showcase. + def sample_unpaid_registration + registration = sample_event_registration + registration.event.cost_cents = 135_000 if registration.event.cost_cents.to_i.zero? + registration.event.payment_due_deadline ||= 1.week.from_now + registration + end + def create_event location = Location.first || Location.create!(city: "Sheboygan", state: "WI") Event.create!(