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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/models/` | ActiveRecord models | ~81 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/models/concerns/` | Shared model modules | 16 concerns |
Expand Down Expand Up @@ -105,7 +105,8 @@ This codebase (Rails 8.1)
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals |
| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship |
| `ProfessionalLicense` | A license a `Person` holds (`number`, `kind`, `issuing_state`, `expires_on`); a null `number` is a placeholder. `find_or_create_for` keeps one license per (person, number) |
| `ContinuingEducationRegistration` | A registrant's CE for one event against one `ProfessionalLicense`; billable `allocatable` (`Registerable`) with stored `hours` + `cost_cents` (default from the event). Payment is computed (no stored status); the certificate is delivered via `certificate_sent_at` and gated by its own `certificate_available?` |
| `TopicSubscription` | A `Person`'s standing subscription to a `TopicSubscriptionType`, optionally narrowed to a specific `interested_event` (null = the topic broadly). State is timestamp-driven (`unsubscribed_at IS NULL` = active β€” `active?`/`unsubscribe!`/`resubscribe` β€” non-bang, since reviving can collide with a newer active row, no status column); `subscribed_at` + `source` mirror the `mailing_list_consent_*` provenance pattern. Distinct from the `mailing_list_consent_*` flag (consent = "you may email me"; subscription = "what I want to hear about") and from an `EventRegistration` (an actual enrollment). One active subscription per (person, type, event) |
Expand Down
26 changes: 23 additions & 3 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def scholarship
end

# Records the recipient agreeing, from their scholarship page, to complete the
# scholarship's tasks. The Agree button submits agreement=yes, which stamps
# agreement_signed_at via the model.
# scholarship's tasks. The Agree button submits agreement=yes, which records an
# "accepted" response via the model.
def sign_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
Expand All @@ -65,13 +65,33 @@ def sign_agreement
end

if params[:agreement] == "yes"
scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed?
scholarship.accept_agreement!(by: "recipient")
redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks β€” your agreement has been recorded."
else
redirect_to registration_scholarship_path(@event_registration.slug), alert: "Something went wrong recording your agreement. Please try again."
end
end

# Records the recipient declining the scholarship, from their scholarship page,
# with an optional reason. Stamps the decline (which drops the award from all
# totals); the team sees it on the scholarship. Re-submitting is a no-op.
def decline_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
redirect_to registration_scholarship_path(@event_registration.slug)
return
end

if scholarship.agreement_declined?
redirect_to registration_scholarship_path(@event_registration.slug), notice: "You've already declined this scholarship. Contact us if you'd like to reconsider."
return
end

scholarship.decline_agreement!(params[:decline_reason].to_s.strip)

redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks for letting us know β€” the team will follow up with you."
end

# CE hours status: hours, amount owed, and license number. The heading and the
# requirements copy live on the materialized ce_hours callout row now.
def ce
Expand Down
15 changes: 14 additions & 1 deletion app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class ScholarshipsController < ApplicationController
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks ]
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks, :reoffer ]
before_action :set_grant, only: [ :new, :create ]

def index
Expand Down Expand Up @@ -108,6 +108,19 @@ def toggle_tasks
end
end

# Re-offer a declined award: back to pending and re-fund the allocation, so the
# recipient can respond again. Explicit admin action (editing the amount alone no
# longer reactivates a decline).
def reoffer
authorize! @scholarship, to: :update?
@scholarship.reoffer_agreement!(by: "admin")
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
notice: "Scholarship re-offered β€” awaiting the recipient's response."
rescue ActiveRecord::RecordInvalid => e
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
alert: e.record.errors.full_messages.to_sentence.presence || "Couldn't re-offer this scholarship."
end

private

# Filter state for the shared report filter partials (time period, event,
Expand Down
4 changes: 2 additions & 2 deletions app/decorators/grant_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ def remaining_percentage
# completed/total. .size / Enumerable count use the preloaded association
# (index eager-loads :scholarships) so these add no per-row queries.
def scholarships_count
object.scholarships.size
object.scholarships.reject(&:agreement_declined?).size
end

def completed_scholarships_count
object.scholarships.count(&:tasks_completed?)
object.scholarships.reject(&:agreement_declined?).count(&:tasks_completed?)
end

# Where the index "Scholarships" count links. When every event-funded
Expand Down
17 changes: 17 additions & 0 deletions app/decorators/scholarship_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,21 @@ def tasks_completed?
def agreement_signed?
object.agreement_signed?
end

def agreement_declined?
object.agreement_declined?
end

# A single agreement-status pill shared by every surface that lists a
# scholarship (indexes, event/registration edit, grant show) so the declined
# state is visible everywhere: Declined (red), Signed (fuchsia), Pending (amber).
def agreement_status_label
return "Declined" if object.agreement_declined?
object.agreement_signed? ? "Signed" : "Pending"
end

def agreement_status_classes
return "bg-red-50 text-red-700 border-red-200" if object.agreement_declined?
object.agreement_signed? ? "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200" : "bg-amber-50 text-amber-700 border-amber-200"
end
end
2 changes: 1 addition & 1 deletion app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ class EventRegistration < ApplicationRecord
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
AND allocations.source_type = 'Scholarship'
AND scholarships.agreement_signed_at IS NOT NULL
AND scholarships.agreement_response_status = 'accepted'
)
SQL
}
Expand Down
12 changes: 6 additions & 6 deletions app/models/grant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def self.self_funded_ids
# funds scopes so they stay flat WHERE clauses β€” no GROUP BY/HAVING, which would
# break will_paginate's total_entries count on the paginated index.
ALLOCATED_CENTS_SUBQUERY =
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id AND scholarships.agreement_response_status <> 'declined'), 0)".freeze

# Grants that still have unallocated funds (donation amount exceeds the sum of
# scholarships drawn against them).
Expand All @@ -41,11 +41,11 @@ def self.self_funded_ids
# exclude grant-less scholarships (grant_id IS NULL) β€” a stray NULL in the
# NOT IN set below would otherwise make all_tasks_completed match nothing.
scope :tasks_outstanding, -> {
where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}
scope :all_tasks_completed, -> {
where(id: Scholarship.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}

# Grants offered in a scholarship's "Funded by grant" picker: every grant with
Expand Down Expand Up @@ -96,9 +96,9 @@ def name_with_funder
# association in memory when present (the index eager-loads :scholarships) to
# avoid a per-row SQL SUM; otherwise issues a single aggregate query.
def scholarships_total_cents
return scholarships.sum { |s| s.amount_cents.to_i } if scholarships.loaded?
return scholarships.reject(&:agreement_declined?).sum { |s| s.amount_cents.to_i } if scholarships.loaded?

scholarships.sum(:amount_cents)
scholarships.not_declined.sum(:amount_cents)
end

def remaining_cents
Expand Down
119 changes: 105 additions & 14 deletions app/models/scholarship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,44 @@ class Scholarship < ApplicationRecord
has_one :allocation, as: :source, dependent: :destroy
has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy
has_many :notifications, as: :noticeable, dependent: :destroy
has_many :agreement_responses, -> { chronological }, class_name: "ScholarshipAgreementResponse", dependent: :destroy

AGREEMENT_RESPONSE_STATUSES = %w[pending accepted declined].freeze

accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? }

validates :amount_cents, numericality: { greater_than_or_equal_to: 0 }
validates :agreement_response_status, inclusion: { in: AGREEMENT_RESPONSE_STATUSES }
validate :recipient_must_match_allocation_registrant
validate :allocation_must_be_valid
validate :within_grant_budget, if: :grant

after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? }
validate :within_grant_budget, if: -> { grant && !agreement_declined? }

# The allocation carries the award financially: zero while declined, else the
# amount. Re-synced on any amount or status change so every allocation-based
# total (balances, dashboards, grant budgets) stays correct.
after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? || saved_change_to_agreement_response_status? }
# Every status transition appends a history row (the audit trail of the
# accept ↔ decline back-and-forth).
after_update :log_agreement_response, if: -> { saved_change_to_agreement_response_status? }
after_create_commit :flag_event_registration_scholarship_requested

scope :completed, -> { where(tasks_completed: true) }
scope :agreement_signed, -> { where.not(agreement_signed_at: nil) }
scope :agreement_signed, -> { where(agreement_response_status: "accepted") }
scope :agreement_declined, -> { where(agreement_response_status: "declined") }
# Declined scholarships are excluded from every total β€” the recipient turned the
# award down, so it no longer counts toward amounts, counts, or budgets.
scope :not_declined, -> { where.not(agreement_response_status: "declined") }

# Funding split (the app-wide convention, mirrored by EventDashboard and
# EventRevenueFigures): externally funded = backed by a grant whose funder isn't
# the org itself; org-subsidized = no grant, or a grant AWBW funded itself.
# Callers rendering both sides can pass an already-loaded self_funded set to
# avoid re-running Grant.self_funded_ids (an Organization.awbw + pluck) per scope.
scope :externally_funded, ->(self_funded = Grant.self_funded_ids) { where.not(grant_id: [ nil, *self_funded ]) }
scope :org_subsidized, ->(self_funded = Grant.self_funded_ids) { where(grant_id: [ nil, *self_funded ]) }
# The funding split excludes declined awards β€” a declined scholarship funds
# nothing, so it counts as neither externally funded nor org-subsidized.
scope :externally_funded, ->(self_funded = Grant.self_funded_ids) { not_declined.where.not(grant_id: [ nil, *self_funded ]) }
scope :org_subsidized, ->(self_funded = Grant.self_funded_ids) { not_declined.where(grant_id: [ nil, *self_funded ]) }

# Scholarships from grants a given funder (Person/Organization) gave β€” the
# "funder" filter. A blank funder matches nothing.
Expand All @@ -50,16 +66,69 @@ def self.event_ids
EventRegistration.where(id: registration_ids).distinct.pluck(:event_id)
end

# The agreement is signed when a signed-at timestamp is present β€” a single
# source of truth. `agreement_signed` reads/writes as a virtual boolean so the
# admin form checkbox and strong params keep working, stamping or clearing the
# timestamp accordingly (and preserving an existing time across re-saves).
def agreement_signed? = agreement_signed_at.present?
# Agreement state is a single tri-state column (pending β†’ accepted β†’ declined),
# so the states are mutually exclusive by construction. `agreement_signed`
# reads/writes as a virtual boolean so the admin form checkbox and strong
# params keep working (checking it accepts, unchecking returns to pending).
def agreement_pending? = agreement_response_status == "pending"
def agreement_signed? = agreement_response_status == "accepted"
def agreement_declined? = agreement_response_status == "declined"
alias_method :agreement_signed, :agreement_signed?

def agreement_signed=(value)
signed = ActiveModel::Type::Boolean.new.cast(value)
self.agreement_signed_at = signed ? (agreement_signed_at || Time.current) : nil
if signed
assign_agreement_response("accepted") unless agreement_signed?
elsif agreement_signed?
assign_agreement_response("pending")
end
end

# The recipient (or an admin) accepting the award. Idempotent β€” a repeat accept
# is a no-op, so it doesn't append a duplicate history row.
def accept_agreement!(by: "recipient")
return if agreement_signed?

assign_agreement_response("accepted", by:)
save!
end

# The recipient declining, with their reason. Recording it (via after_update)
# zeroes the allocation so the award stops counting in every total and appends
# a history row; the row is kept for history.
def decline_agreement!(reason, by: "recipient")
assign_agreement_response("declined", reason:, by:)
save!
end

# Admin re-offering a declined award: back to pending (the recipient decides
# again) and the allocation is re-funded to the current amount. Explicit action β€”
# editing the amount alone no longer reactivates a decline.
def reoffer_agreement!(by: "admin")
return if agreement_pending?

assign_agreement_response("pending", by:)
save!
end

# The event registration this scholarship is allocated against (nil for a
# grant-funded scholarship with no registration).
def event_registration
registration = allocation&.allocatable
registration if registration.is_a?(EventRegistration)
end

# The event this scholarship was awarded at, via its allocation's registration
# (nil for a grant-funded scholarship with no event registration).
def event
event_registration&.event
end

# The current agreement response β€” the source for the responded-at date and
# decline reason (which aren't stored on the scholarship; only the status is).
# Nil while pending with no response yet.
def latest_agreement_response
agreement_responses.loaded? ? agreement_responses.max_by(&:responded_at) : agreement_responses.chronological.last
end

def amount_dollars
Expand All @@ -81,7 +150,7 @@ def communications_email
def within_grant_budget
return unless amount_cents

others_total = grant.scholarships.where.not(id: id).sum(:amount_cents)
others_total = grant.scholarships.not_declined.where.not(id: id).sum(:amount_cents)
if others_total + amount_cents > grant.amount_cents
errors.add(:amount_cents, "would exceed the grant's available funds")
end
Expand Down Expand Up @@ -109,10 +178,32 @@ def recipient_must_match_allocation_registrant
end
end

# Assign the new agreement state in memory (persisted by the caller's save).
# The reason + responder are stashed for the history row the after_update
# callback writes β€” they live on the response, not on the scholarship.
def assign_agreement_response(status, reason: nil, by: "admin")
self.agreement_response_status = status
@agreement_response_reason = (status == "declined" ? reason.presence : nil)
@agreement_response_by = by
end

def sync_allocation_amount
return unless allocation

allocation.update!(amount: amount_cents.to_i)
desired = agreement_declined? ? 0 : amount_cents.to_i
allocation.update!(amount: desired) unless allocation.amount == desired
end

def log_agreement_response
agreement_responses.create!(
status: agreement_response_status,
reason: @agreement_response_reason,
responded_at: Time.current,
responder: @agreement_response_by.presence || "admin",
amount_cents: amount_cents
)
@agreement_response_reason = nil
@agreement_response_by = nil
end

# When a scholarship is awarded against an event registration, the registration
Expand Down
16 changes: 16 additions & 0 deletions app/models/scholarship_agreement_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class ScholarshipAgreementResponse < ApplicationRecord
# One row per agreement transition, so the back-and-forth between a recipient
# and the team (accept ↔ decline, and admin re-offers) is a first-class,
# queryable history. The scholarship's agreement_response_status is the
# denormalized cache of the latest row here.
STATUSES = %w[pending accepted declined].freeze
RESPONDERS = %w[recipient admin system].freeze

belongs_to :scholarship

validates :status, inclusion: { in: STATUSES }
validates :responder, inclusion: { in: RESPONDERS }, allow_nil: true
validates :responded_at, presence: true

scope :chronological, -> { order(:responded_at, :id) }
end
Loading