Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions app/jobs/event_payment_reminders_job.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/jobs/notification_mailer_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
30 changes: 30 additions & 0 deletions app/mailers/event_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions app/views/event_mailer/event_payment_reminder.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<h1><%= @phase == :overdue ? "Payment past due" : "Payment reminder" %></h1>

<div style="margin-top: 36px; text-align: left;">
<p>Hello <strong><%= @person.full_name %></strong>,</p>

<% case @phase
when :week %>
<p>This is a friendly reminder that your payment for the event below is due in about a week.</p>
<% when :day %>
<p>This is a reminder that your payment for the event below is due tomorrow.</p>
<% when :overdue %>
<p>Our records show your payment for the event below is now past due and hasn't been received yet.</p>
<% end %>

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

<div style="text-align: center; background-color: #fef3c7; border-radius: 6px; padding: 20px; margin: 16px 0;">
<p style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; margin: 0 0 4px;">Amount due</p>
<p style="font-size: 28px; font-weight: bold; color: #92400e; margin: 0;"><%= dollars_from_cents(@amount_due) %></p>
<% if (due_display = @event.payment_due_deadline_display) %>
<p style="font-size: 14px; color: #92400e; margin: 8px 0 0;">Due by <strong><%= due_display %></strong></p>
<% end %>
</div>
</div>

<% if @event_registration.persisted? && @event_registration.slug.present? %>
<p>You can pay online or view your payment options here:</p>
<p><a href="<%= registration_payment_url(@event_registration.slug) %>" class="button">Make your payment</a></p>
<% end %>

<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">This is an automated reminder from <%= @organization_name %>. If you've already paid, please disregard this message.</p>
25 changes: 25 additions & 0 deletions app/views/event_mailer/event_payment_reminder.text.erb
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 109 additions & 0 deletions spec/jobs/event_payment_reminders_job_spec.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions spec/mailers/event_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading